fix(ci): compare integrated benchmarks on the same runner (#15228)
Compare integrated benchmark samples against main measured on the same runner. Keep absolute minimum timings in Bencher for historical tracking without letting cross-machine historical alerts overwrite the PR regression check. Require at least nine successful samples per target. Fail when the median slowdown and retained sample separation both exceed 5% of main's median or 2 ms, whichever is larger. Trim up to 10% from each tail. Report other median slowdowns above that tolerance as inconclusive. This conservative noise guard is not a statistical confidence interval; smaller or noisy regressions can pass, and sequential measurements remain vulnerable to runner drift. Run the comparison from trusted default-branch code in the privileged comment workflow, publish the report before enforcing the result, and preserve the existing check name on the triggering PR commit. Keep the peer-heavy Rust-versus-TypeScript assertion unchanged. Cover regressions, shared machine slowdowns, outliers, invalid data, all scenario and engine combinations, command names, CLI exit codes, and the duration-dependent tolerance with Node tests. Keep the explanation in the existing benchmark documentation.
This commit is contained in:
1 parent
222513ac46
commit
bd4d526530
5 files changed
+320
-32
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export const scenarios = [
|
||||
'ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE',
|
||||
'ISOLATED_FRESH_RESTORE_HOT_CACHE_HOT_STORE',
|
||||
'ISOLATED_REPEAT_INSTALL_HOT_CACHE_HOT_STORE',
|
||||
'ISOLATED_REPEAT_INSTALL_COLD_CACHE_HOT_STORE',
|
||||
'ISOLATED_FRESH_INSTALL_COLD_CACHE_COLD_STORE',
|
||||
'ISOLATED_FRESH_INSTALL_HOT_CACHE_HOT_STORE',
|
||||
'ISOLATED_FRESH_INSTALL_COLD_CACHE_HOT_STORE',
|
||||
'ISOLATED_FRESH_RESOLVE_HOT_CACHE_OFFLINE',
|
||||
'ISOLATED_PEER_HEAVY_RESOLVE_HOT_CACHE_OFFLINE',
|
||||
'ISOLATED_LINKED_WORKSPACE_RESOLVE_HOT_CACHE_OFFLINE',
|
||||
'ISOLATED_FRESH_RESTORE_COLD_CACHE_COLD_STORE_COLD_PNPR',
|
||||
'GVS_FRESH_RESTORE_HOT_CACHE_HOT_STORE',
|
||||
]
|
||||
|
||||
export function compare (report, engine) {
|
||||
const head = samples(report, `${engine}@HEAD`)
|
||||
const base = samples(report, `${engine}@main`)
|
||||
const headMedian = median(head)
|
||||
const baseMedian = median(base)
|
||||
const tolerance = Math.max(baseMedian * 0.05, 0.002)
|
||||
const ratio = headMedian / baseMedian
|
||||
// Trim at most 10% of each tail. This is an empirical noise guard, not a
|
||||
// confidence interval: hyperfine measures commands sequentially.
|
||||
const headLow = head[Math.floor(head.length / 10)]
|
||||
const baseHigh = base[base.length - 1 - Math.floor(base.length / 10)]
|
||||
const status = headMedian <= baseMedian + tolerance ? 'Within tolerance' : headLow > baseHigh + tolerance
|
||||
? 'Regression' : 'Inconclusive (overlapping samples)'
|
||||
return { head: headMedian, base: baseMedian, ratio, tolerance, status }
|
||||
}
|
||||
|
||||
function samples (report, name) {
|
||||
const matches = report.results.filter(result => (result.command_name ?? result.command) === name)
|
||||
if (matches.length !== 1) throw new Error(`Expected one result for ${name}`)
|
||||
const { times, exit_codes: exitCodes } = matches[0]
|
||||
if (!Array.isArray(times) || times.length < 9 || times.some(time => !Number.isFinite(time) || time <= 0)) {
|
||||
throw new Error(`Expected at least 9 positive finite samples for ${name}`)
|
||||
}
|
||||
if (!Array.isArray(exitCodes) || exitCodes.length !== times.length || exitCodes.some(code => code !== 0)) {
|
||||
throw new Error(`Missing or unsuccessful exit codes for ${name}`)
|
||||
}
|
||||
return [...times].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function median (values) {
|
||||
const middle = Math.floor(values.length / 2)
|
||||
return values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2
|
||||
}
|
||||
|
||||
export async function renderComparison (directory) {
|
||||
const lines = [
|
||||
'### Same-run regression check',
|
||||
'',
|
||||
'HEAD and main are measured on the same runner. The tolerance is 5% of the main median or 2 ms, whichever is larger. A regression requires both the median slowdown and the gap between the fastest retained HEAD sample and slowest retained main sample to exceed this tolerance, after trimming up to 10% from each tail. This conservative noise guard is not a statistical confidence interval.',
|
||||
'',
|
||||
'Inconclusive rows do not fail the check. They remain visible for performance review. Bencher retains absolute minimum timings for historical tracking; historical alerts do not gate this PR. Sequential measurements can still be affected by runner drift.',
|
||||
'',
|
||||
'| Scenario | Engine | main median | HEAD median | Change | Tolerance | Result |',
|
||||
'| --- | --- | ---: | ---: | ---: | ---: | --- |',
|
||||
]
|
||||
let failed = false
|
||||
for (const scenario of scenarios) {
|
||||
const report = JSON.parse(await readFile(join(directory, `BENCHMARK_REPORT_${scenario}.json`), 'utf8'))
|
||||
const engines = scenario.includes('PEER_HEAVY') || scenario.includes('LINKED_WORKSPACE') ? ['pacquet'] : ['pacquet', 'pnpr']
|
||||
for (const engine of engines) {
|
||||
const result = compare(report, engine)
|
||||
failed ||= result.status === 'Regression'
|
||||
lines.push(`| ${scenario.toLowerCase().replaceAll('_', '-')} | ${engine} | ${(result.base * 1000).toFixed(2)} ms | ${(result.head * 1000).toFixed(2)} ms | ${((result.ratio - 1) * 100).toFixed(1)}% | ${(result.tolerance * 1000).toFixed(2)} ms | ${result.status} |`)
|
||||
}
|
||||
}
|
||||
return { markdown: lines.join('\n') + '\n', failed }
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
const { markdown, failed } = await renderComparison(process.argv[2])
|
||||
console.log(markdown)
|
||||
process.exitCode = failed ? 1 : 0
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { test } from 'node:test'
|
||||
import { compare, renderComparison, scenarios } from './compare-integrated-benchmarks.mjs'
|
||||
|
||||
function command (name, times) {
|
||||
return { command: name, times, exit_codes: times.map(() => 0) }
|
||||
}
|
||||
|
||||
function report (head, base) {
|
||||
return { results: [command('pacquet@HEAD', head), command('pacquet@main', base)] }
|
||||
}
|
||||
|
||||
const stable = Array(10).fill(1)
|
||||
|
||||
test('a slower machine affects both revisions without causing an alert', () => {
|
||||
assert.equal(compare(report(stable.map(x => x * 3.1), stable.map(x => x * 3)), 'pacquet').status, 'Within tolerance')
|
||||
})
|
||||
|
||||
test('a consistent regression fails, an improvement passes', () => {
|
||||
assert.equal(compare(report(Array(9).fill(1.3), Array(9).fill(1)), 'pacquet').status, 'Regression')
|
||||
assert.equal(compare(report(stable, stable.map(x => x * 1.3)), 'pacquet').status, 'Within tolerance')
|
||||
})
|
||||
|
||||
test('multi-second scenarios catch consistent slowdowns below 20%', () => {
|
||||
const result = compare(report(Array(10).fill(3.18), Array(10).fill(3)), 'pacquet')
|
||||
assert.equal(result.status, 'Regression')
|
||||
assert.equal(result.tolerance, 3 * 0.05)
|
||||
})
|
||||
|
||||
test('short scenarios tolerate up to 2 ms but still catch larger slowdowns', () => {
|
||||
const base = Array(10).fill(0.008)
|
||||
assert.equal(compare(report(Array(10).fill(0.009), base), 'pacquet').status, 'Within tolerance')
|
||||
assert.equal(compare(report(Array(10).fill(0.010), base), 'pacquet').status, 'Within tolerance')
|
||||
const result = compare(report(Array(10).fill(0.011), base), 'pacquet')
|
||||
assert.equal(result.status, 'Regression')
|
||||
assert.equal(result.tolerance, 0.002)
|
||||
})
|
||||
|
||||
test('exactly 5% is tolerated when the relative threshold is larger', () => {
|
||||
assert.equal(compare(report(Array(10).fill(1.05), stable), 'pacquet').status, 'Within tolerance')
|
||||
})
|
||||
|
||||
test('a noisy slowdown is explicitly inconclusive', () => {
|
||||
assert.equal(compare(report([1, 1, 1.3, 1.3, 1.3, 1.3, 1.4, 1.4, 2, 3], stable), 'pacquet').status, 'Inconclusive (overlapping samples)')
|
||||
})
|
||||
|
||||
test('isolated outliers neither create nor conceal a regression', () => {
|
||||
assert.equal(compare(report([...stable.slice(1), 10], stable), 'pacquet').status, 'Within tolerance')
|
||||
assert.equal(compare(report([0.5, ...Array(9).fill(1.3)], [...stable.slice(1), 10]), 'pacquet').status, 'Regression')
|
||||
})
|
||||
|
||||
test('missing, duplicate, failed, undersampled and invalid data fail closed', () => {
|
||||
const valid = report(stable, stable)
|
||||
const invalid = [
|
||||
{ results: [] },
|
||||
{ results: [...valid.results, valid.results[0]] },
|
||||
report(stable.slice(0, 8), stable),
|
||||
report([...stable.slice(1), NaN], stable),
|
||||
report([...stable.slice(1), 0], stable),
|
||||
{ results: [{ ...valid.results[0], exit_codes: [0] }, valid.results[1]] },
|
||||
{ results: [{ ...valid.results[0], exit_codes: [1, ...Array(9).fill(0)] }, valid.results[1]] },
|
||||
]
|
||||
for (const input of invalid) assert.throws(() => compare(input, 'pacquet'))
|
||||
})
|
||||
|
||||
test('all scenarios and both engines are checked, and missing reports fail', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'benchmark-comparison-'))
|
||||
try {
|
||||
for (const scenario of scenarios) {
|
||||
const input = report(stable, stable)
|
||||
if (!scenario.includes('PEER_HEAVY') && !scenario.includes('LINKED_WORKSPACE')) {
|
||||
input.results.push(command('pnpr@HEAD', Array(10).fill(1.4)), command('pnpr@main', stable))
|
||||
}
|
||||
await writeFile(join(directory, `BENCHMARK_REPORT_${scenario}.json`), JSON.stringify(input))
|
||||
}
|
||||
const result = await renderComparison(directory)
|
||||
assert.equal(result.failed, true)
|
||||
assert.equal(result.markdown.match(/\| Regression \|/g).length, 10)
|
||||
assert.equal(result.markdown.match(/\| Within tolerance \|/g).length, 12)
|
||||
const cli = new URL('./compare-integrated-benchmarks.mjs', import.meta.url)
|
||||
const run = spawnSync(process.execPath, [fileURLToPath(cli), directory], { encoding: 'utf8' })
|
||||
assert.equal(run.status, 1)
|
||||
assert.match(run.stdout, /Regression/)
|
||||
assert.equal(run.stderr, '')
|
||||
for (const scenario of scenarios) {
|
||||
const input = { results: ['pacquet', 'pnpr'].flatMap(engine => [command(`${engine}@HEAD`, stable), command(`${engine}@main`, stable)]) }
|
||||
await writeFile(join(directory, `BENCHMARK_REPORT_${scenario}.json`), JSON.stringify(input))
|
||||
}
|
||||
assert.equal(spawnSync(process.execPath, [fileURLToPath(cli), directory]).status, 0)
|
||||
await rm(join(directory, `BENCHMARK_REPORT_${scenarios[0]}.json`))
|
||||
await assert.rejects(renderComparison(directory), /ENOENT/)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('comparison and Bencher uploads cover every matrix scenario', async () => {
|
||||
const workflow = await readFile(new URL('../workflows/pacquet-integrated-benchmark.yml', import.meta.url), 'utf8')
|
||||
const tags = [...workflow.matchAll(/'([A-Z_]+):[a-z][^']+'/g)].map(match => match[1])
|
||||
const matrix = workflow.match(/ scenario:\n((?: - [^\n]+\n)+)/)[1]
|
||||
const matrixTags = [...matrix.matchAll(/- ([a-z][\w.-]+)/g)].map(match => match[1]
|
||||
.replace('-linker.', '_').replaceAll(/[.-]/g, '_').toUpperCase())
|
||||
assert.deepEqual([...new Set(tags)].sort(), [...matrixTags].sort())
|
||||
assert.deepEqual([...scenarios].sort(), [...matrixTags].sort())
|
||||
})
|
||||
|
||||
test('hyperfine command names identify targets when commands are shell scripts', () => {
|
||||
const input = report(stable, stable)
|
||||
input.results = input.results.map(result => ({ ...result, command_name: result.command, command: '/tmp/run.sh' }))
|
||||
assert.equal(compare(input, 'pacquet').status, 'Within tolerance')
|
||||
})
|
||||
@@ -101,6 +101,15 @@ jobs:
|
||||
fi
|
||||
echo "pr=$pr" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout trusted comparison script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
# workflow_run's SHA belongs to the default branch, never the PR.
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
path: trusted-tools
|
||||
|
||||
- name: Download benchmark report
|
||||
# `run-id` + `github-token` lets v8 pull artifacts from a different
|
||||
# workflow run, which is exactly the workflow_run pattern.
|
||||
@@ -111,6 +120,25 @@ jobs:
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compare HEAD with main from the same runner
|
||||
id: comparison
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
node trusted-tools/.github/scripts/compare-integrated-benchmarks.mjs benchmark-artifact > comparison.md
|
||||
|
||||
- name: Add comparison to report
|
||||
if: always() && (steps.comparison.outcome == 'success' || steps.comparison.outcome == 'failure')
|
||||
shell: bash
|
||||
run: |
|
||||
if [ ! -s comparison.md ]; then
|
||||
echo '### Same-run regression check' > comparison.md
|
||||
echo 'Comparison failed because benchmark data was missing or invalid. See the workflow log.' >> comparison.md
|
||||
fi
|
||||
cat comparison.md >> "$GITHUB_STEP_SUMMARY"
|
||||
cat comparison.md benchmark-artifact/SUMMARY.md > combined-summary.md
|
||||
mv combined-summary.md benchmark-artifact/SUMMARY.md
|
||||
|
||||
- name: Find existing comment
|
||||
# Match on the literal substring the report header carries.
|
||||
# Updating the heading text in `integrated-benchmark.yml` (the
|
||||
@@ -141,9 +169,8 @@ jobs:
|
||||
# workflow_run runs in the base-repo privilege context, so
|
||||
# BENCHER_API_TOKEN is available even for fork PRs. PR number
|
||||
# comes from the trusted workflow_run payload (resolved in
|
||||
# `meta` above), never from the artifact body. `--ci-number`
|
||||
# is required here because Bencher can't infer the PR from a
|
||||
# workflow_run event the way it does on pull_request.
|
||||
# `meta` above), never from the artifact body. Historical results
|
||||
# are retained without creating a separate historical-threshold check.
|
||||
#
|
||||
# One benchmark, two testbeds: `pacquet` (direct install) and
|
||||
# `pnpr` (the accelerated path). Each uploads from its own
|
||||
@@ -151,7 +178,6 @@ jobs:
|
||||
if: hashFiles('benchmark-artifact/bencher-results.json') != ''
|
||||
env:
|
||||
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ steps.meta.outputs.pr }}
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -159,9 +185,6 @@ jobs:
|
||||
echo "::notice::BENCHER_API_TOKEN not set, skipping Bencher upload"
|
||||
exit 0
|
||||
fi
|
||||
# `--start-point-clone-thresholds` so the PR branch inherits the
|
||||
# threshold configured on main; `--err` so the action surfaces
|
||||
# a regression on the PR check.
|
||||
upload() {
|
||||
local testbed=$1 file=$2
|
||||
if [ ! -f "$file" ]; then
|
||||
@@ -175,11 +198,41 @@ jobs:
|
||||
--start-point main \
|
||||
--start-point-reset \
|
||||
--start-point-clone-thresholds \
|
||||
--err \
|
||||
--adapter json \
|
||||
--file "$file" \
|
||||
--ci-number "$PR_NUMBER" \
|
||||
--github-actions "$GITHUB_TOKEN"
|
||||
--file "$file"
|
||||
}
|
||||
upload pacquet benchmark-artifact/bencher-results.json
|
||||
upload pnpr benchmark-artifact/bencher-results-pnpr.json
|
||||
|
||||
- name: Publish regression check on the PR commit
|
||||
if: always() && steps.meta.outputs.pr != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
OUTCOME: ${{ steps.comparison.outcome }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
shell: bash
|
||||
run: |
|
||||
conclusion=failure
|
||||
if [ "$OUTCOME" = success ]; then
|
||||
conclusion=success
|
||||
fi
|
||||
if [ ! -s comparison.md ]; then
|
||||
echo 'Benchmark comparison could not complete. See the workflow log.' > comparison.md
|
||||
fi
|
||||
# Preserve the existing required check name while replacing its
|
||||
# historical threshold with the same-run comparison.
|
||||
jq -n --arg sha "$HEAD_SHA" --arg conclusion "$conclusion" \
|
||||
--arg url "$RUN_URL" --rawfile summary comparison.md \
|
||||
--arg name "Bencher Report (pnpm's project)" \
|
||||
'{name: $name, head_sha: $sha, status: "completed", conclusion: $conclusion,
|
||||
details_url: $url, output: {title: "Same-run benchmark comparison", summary: $summary}}' \
|
||||
> check.json
|
||||
gh api --method POST "repos/$REPO/check-runs" --input check.json
|
||||
|
||||
- name: Enforce same-run regression check
|
||||
if: always() && steps.comparison.outcome != 'success'
|
||||
run: |
|
||||
echo '::error::Same-run benchmark comparison failed. See the comparison report and workflow log.'
|
||||
exit 1
|
||||
@@ -28,6 +28,8 @@ on:
|
||||
- '.github/actions/binstall/**'
|
||||
- '.github/actions/install-crates/**'
|
||||
- '.github/workflows/pacquet-integrated-benchmark.yml'
|
||||
- '.github/workflows/pacquet-integrated-benchmark-comment.yml'
|
||||
- '.github/scripts/compare-integrated-benchmarks*.mjs'
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths:
|
||||
@@ -46,6 +48,8 @@ on:
|
||||
- '.github/actions/binstall/**'
|
||||
- '.github/actions/install-crates/**'
|
||||
- '.github/workflows/pacquet-integrated-benchmark.yml'
|
||||
- '.github/workflows/pacquet-integrated-benchmark-comment.yml'
|
||||
- '.github/scripts/compare-integrated-benchmarks*.mjs'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
@@ -679,6 +683,9 @@ jobs:
|
||||
ref: ${{ needs.revisions.outputs.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test benchmark comparison
|
||||
run: node --test .github/scripts/compare-integrated-benchmarks.test.mjs
|
||||
|
||||
- name: Download scenario results
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -702,7 +709,7 @@ jobs:
|
||||
echo
|
||||
echo 'Regular scenarios report direct and pnpr installs. The peer-heavy resolver scenario compares current Rust, main Rust, and TypeScript pnpm; the linked-workspace scenario compares current Rust against main Rust. Bencher consumes pacquet@HEAD and pnpr@HEAD.'
|
||||
echo
|
||||
echo 'The tables below show mean ± σ; Bencher thresholds on the **minimum** latency, which is far less perturbed by shared-runner contention (noise only adds time).'
|
||||
echo 'The tables below show mean ± σ. Bencher tracks absolute minimum latency; PR regression checks compare HEAD and main samples from the same runner. Historical timing alerts do not gate PRs.'
|
||||
echo
|
||||
echo '### Scenario: Isolated linker: fresh restore, cold cache + cold store'
|
||||
echo
|
||||
@@ -858,19 +865,9 @@ jobs:
|
||||
# scenarios contribute both testbeds; the client-only peer-heavy case
|
||||
# contributes only `pacquet@HEAD`.
|
||||
#
|
||||
# We report each scenario's *minimum* latency as the metric value, so
|
||||
# Bencher's threshold tracks the min rather than the mean. On a shared
|
||||
# CI runner, contention only ever *adds* time, so the minimum is the run
|
||||
# least perturbed by a noisy neighbour and the truest signal of a real
|
||||
# code regression — the mean drifts run-to-run with whatever else the
|
||||
# machine is doing (the latency-free offline scenario is pure CPU/disk
|
||||
# and swung ~30% between runs, tripping a spurious alert on a control
|
||||
# run whose HEAD and main binaries were byte-identical). Because the
|
||||
# minimum only needs one uncontended run, hyperfine's default sampling
|
||||
# suffices for regular scenarios. The peer-heavy case is noisier and
|
||||
# fixes its own count instead. The human-readable
|
||||
# comment still reports mean ± σ; only the alerting signal moves to the
|
||||
# min.
|
||||
# Keep absolute minimum latency for historical continuity in Bencher.
|
||||
# The PR gate uses same-run sample distributions in the comment
|
||||
# workflow; historical timings vary with the machine running them.
|
||||
#
|
||||
# BMF rather than the `shell_hyperfine` adapter with a doctored `mean`:
|
||||
# this reports the min honestly as the value instead of relabelling it.
|
||||
@@ -969,7 +966,6 @@ jobs:
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
BENCHER_API_TOKEN: ${{ secrets.BENCHER_API_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -980,10 +976,8 @@ jobs:
|
||||
# One run is one benchmark, two testbeds: `pacquet` (direct
|
||||
# install, all scenarios) and `pnpr` (accelerated path, hot
|
||||
# scenarios). Upload each from its own bencher-results file.
|
||||
# `--start-point-clone-thresholds` so the forked branch inherits
|
||||
# the threshold configured on main; `--err` so the workflow fails
|
||||
# when a sample breaches the upper boundary. Main pushes skip
|
||||
# both — by then the regression has already landed.
|
||||
# Historical alerts remain in Bencher. Only same-run comparisons
|
||||
# gate branches, since absolute times vary between runners.
|
||||
upload() {
|
||||
local testbed=$1 file=$2
|
||||
if [ ! -f "$file" ]; then
|
||||
@@ -995,7 +989,6 @@ jobs:
|
||||
--testbed "$testbed"
|
||||
--adapter json
|
||||
--file "$file"
|
||||
--github-actions "$GITHUB_TOKEN"
|
||||
)
|
||||
if [ "$REF_NAME" = "main" ]; then
|
||||
args+=(--branch main)
|
||||
@@ -1005,7 +998,6 @@ jobs:
|
||||
--start-point main
|
||||
--start-point-reset
|
||||
--start-point-clone-thresholds
|
||||
--err
|
||||
)
|
||||
fi
|
||||
bencher run "${args[@]}"
|
||||
@@ -1013,6 +1005,14 @@ jobs:
|
||||
upload pacquet bench-work-env/bencher-results.json
|
||||
upload pnpr bench-work-env/bencher-results-pnpr.json
|
||||
|
||||
- name: Check same-run regressions on dispatched branches
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref_name != 'main'
|
||||
shell: bash
|
||||
run: |
|
||||
node .github/scripts/compare-integrated-benchmarks.mjs bench-work-env > comparison.md || status=$?
|
||||
cat comparison.md >> "$GITHUB_STEP_SUMMARY"
|
||||
exit "${status:-0}"
|
||||
|
||||
- name: Re-raise the peer-heavy gate
|
||||
# Deferred from the peer-heavy step so its cross-engine assertion
|
||||
# cannot abort the job before the run's data points are published.
|
||||
|
||||
@@ -205,3 +205,40 @@ just integrated-benchmark --scenario=isolated-linker.fresh-restore.cold-cache.co
|
||||
# See more options
|
||||
just integrated-benchmark --help
|
||||
```
|
||||
|
||||
### CI regression checks
|
||||
|
||||
Each PR benchmark scenario measures HEAD and main on the same runner. The check
|
||||
uses a tolerance of **5% of main's median or 2 ms, whichever is larger**. That is
|
||||
2 ms for a 10 ms baseline and 150 ms for a 3 second baseline.
|
||||
|
||||
A regression requires both the median slowdown and the gap between the fastest
|
||||
retained HEAD sample and slowest retained main sample to exceed that tolerance.
|
||||
The comparison trims `floor(sample count / 10)` samples from each tail and
|
||||
requires at least nine successful samples per target. Missing or invalid data
|
||||
fails the check. Both pacquet and pnpr are checked, except in the two client-only
|
||||
resolver scenarios.
|
||||
|
||||
Slowdowns above the tolerance without this sample separation are reported as
|
||||
inconclusive and do not fail CI. This conservative noise guard is not a
|
||||
statistical confidence interval; sequential measurements remain vulnerable to
|
||||
runner drift. All timings remain available for performance review.
|
||||
|
||||
Bencher retains absolute minimum timings for historical tracking. Historical
|
||||
alerts do not gate PRs, since their baselines may come from faster machines.
|
||||
The privileged comment workflow runs the comparator from the default branch,
|
||||
treats PR artifacts only as data, posts the report before enforcing the result,
|
||||
and preserves the `Bencher Report (pnpm's project)` check name. Non-main manual
|
||||
dispatches use the same comparison. The peer-heavy Rust-versus-TypeScript
|
||||
speedup assertion remains active.
|
||||
|
||||
To replay a downloaded `integrated-benchmark-report-ubuntu-latest` artifact,
|
||||
run these commands from the repository root:
|
||||
|
||||
```sh
|
||||
node .github/scripts/compare-integrated-benchmarks.mjs /path/to/artifact
|
||||
node --test .github/scripts/compare-integrated-benchmarks.test.mjs
|
||||
```
|
||||
|
||||
Adding a scenario requires updating the comparison script's scenario list and
|
||||
the workflow. The tests check that both cover the benchmark matrix.
|
||||
Reference in new issue
Block a user