Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .github/actions/benchmark-capture/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
name: "📊 capture benchmark"
description: "Capture benchmarks for the base and head."

inputs:
base-repository:
description: "Repository"
required: true
base-sha:
description: "Base commit SHA"
required: true
head-sha:
description: "Head commit SHA"
required: true
capture-command:
description: "Benchmark Capture command"
required: true
compare-command:
description: "Benchmark Comparison command"
required: true
base-results-path:
description: "Relative base results directory"
required: true
head-results-path:
description: "Relative head results directory"
required: true

outputs:
artifact-id:
description: "Results Artifact ID"
value: ${{ steps.results.outputs.artifact-id }}

runs:
using: composite
steps:
- name: "📥 check out base"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ inputs.base-repository }}
ref: ${{ inputs.base-sha }}
path: benchmark-base
persist-credentials: false

- name: "⚙️ set up base"
shell: bash
working-directory: benchmark-base
run: |
pnpm install --frozen-lockfile
pnpm build

- name: "📂 stage base output"
shell: bash
run: |
mkdir -p dist
cp -R benchmark-base/dist/. dist/

- name: "📊 capture base benchmark"
shell: bash
env:
COMMAND: ${{ inputs.capture-command }}
run: bash -eo pipefail -c "$COMMAND"

- name: "⚙️ set up head"
shell: bash
run: pnpm build

- name: "📊 compare benchmark"
shell: bash
env:
COMMAND: ${{ inputs.compare-command }}
run: bash -eo pipefail -c "$COMMAND"

- name: "📂 collect results"
shell: bash
env:
BASE_SHA: ${{ inputs.base-sha }}
HEAD_SHA: ${{ inputs.head-sha }}
BASE_RESULTS_PATH: ${{ inputs.base-results-path }}
HEAD_RESULTS_PATH: ${{ inputs.head-results-path }}
run: |
mkdir -p benchmark-results/base benchmark-results/head
cp "$BASE_RESULTS_PATH"/*.json benchmark-results/base/
cp "$HEAD_RESULTS_PATH"/*.json benchmark-results/head/
node -e 'require("node:fs").writeFileSync("benchmark-results/measurement.json", JSON.stringify({base: process.env.BASE_SHA, head: process.env.HEAD_SHA}))'

- name: "📤 upload results"
id: results
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: benchmark-results-${{ github.run_attempt }}
path: benchmark-results/
if-no-files-found: error
retention-days: 1
41 changes: 41 additions & 0 deletions .github/actions/benchmark-report/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: "📝 report benchmarks"
description: "Download benchmark results and publish the report"

inputs:
artifact-id:
description: "Results Artifact ID"
required: true
base-sha:
description: "Base commit SHA"
required: true
head-sha:
description: "Head commit SHA"
required: true
unit:
description: "Mean latency unit label"
default: "ms/operation"
operations-per-sample:
description: "Operations p/sample"
default: "1"

runs:
using: composite
steps:
- name: "📥 download results"
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
artifact-ids: ${{ inputs.artifact-id }}
path: ${{ runner.temp }}/benchmark-results

- name: "📝 publish results"
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BASE_SHA: ${{ inputs.base-sha }}
HEAD_SHA: ${{ inputs.head-sha }}
RESULTS: ${{ runner.temp }}/benchmark-results
RESULT_UNIT: ${{ inputs.unit }}
OPERATIONS_PER_SAMPLE: ${{ inputs.operations-per-sample }}
with:
script: |
const { publish } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/benchmark-report.mjs`);
await publish({ github, context, core });
170 changes: 170 additions & 0 deletions .github/scripts/benchmark-report.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/* oxlint-disable typescript/no-unsafe-member-access, typescript/no-unsafe-return */
// Native github-script API objects and parsed JSON have no SDK types here.

import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';

export async function publish({ github, context, core }) {
const operationsPerSample = Number(process.env.OPERATIONS_PER_SAMPLE ?? '1');
const unit = process.env.RESULT_UNIT ?? 'ms/operation';

const source = {
pr: context.payload.pull_request.number,
run: context.runId,
number: context.runNumber,
attempt: Number(process.env.GITHUB_RUN_ATTEMPT),
base: process.env.BASE_SHA,
head: process.env.HEAD_SHA,
};

const repository = context.repo;

const skip = (reason) => {
core.notice(`Benchmark report skipped: ${reason}`);
};

let rows;

try {
if (!Number.isFinite(operationsPerSample) || operationsPerSample <= 0) {
skip('operations per sample must be finite and positive');

return;
}

const measurement = await readJson(path.join(process.env.RESULTS, 'measurement.json'));

if (measurement?.base !== source.base || measurement?.head !== source.head) {
skip('measurement SHAs do not match the producer base and head');

return;
}

const baseDirectory = path.join(process.env.RESULTS, 'base');
const headDirectory = path.join(process.env.RESULTS, 'head');
const baseEntries = await readdir(baseDirectory);
const headEntries = await readdir(headDirectory);
const baseFiles = baseEntries.toSorted((a, b) => a.localeCompare(b));
const headFiles = headEntries.toSorted((a, b) => a.localeCompare(b));

if (
baseFiles.length === 0 ||
baseFiles.length !== headFiles.length ||
baseFiles.some((file, index) => file !== headFiles[index] || !/^[a-z0-9][a-z0-9.-]{0,95}\.json$/u.test(file))
) {
skip('native results must contain matching JSON files with safe workload IDs');

return;
}

rows = [];

for (const file of baseFiles) {
const baseResult = await readJson(path.join(baseDirectory, file));
const headResult = await readJson(path.join(headDirectory, file));
const base = Number.isFinite(baseResult?.latency?.mean) ? baseResult.latency.mean / operationsPerSample : Number.NaN;
const head = Number.isFinite(headResult?.latency?.mean) ? headResult.latency.mean / operationsPerSample : Number.NaN;
const change = (head / base - 1) * 100;

if (!Number.isFinite(base) || base <= 0 || !Number.isFinite(head) || head <= 0 || !Number.isFinite(change)) {
skip('latency means and normalized values must be finite and positive');

return;
}

rows.push(
`| \`${file.slice(0, -5)}\` | ${base.toPrecision(4)} | ${head.toPrecision(4)} | ${change >= 0 ? '+' : ''}${change.toPrecision(3)}% |`,
);
}
} catch {
skip('native result JSON is missing, unreadable, or invalid');

return;
}

const marker = '<!-- benchmark-report -->';

const comments = await github.paginate(github.rest.issues.listComments, {
...repository,
issue_number: source.pr,
per_page: 100,
});

const matching = comments.filter(
(comment) =>
comment.user?.type === 'Bot' && comment.user.login === 'github-actions[bot]' && comment.body?.startsWith(marker),
);

if (matching.length > 1) {
skip('multiple bot report comments exist');

return;
}

const comment = matching[0];

if (comment) {
const previous = comment.body.match(/<!-- benchmark-run:([a-f0-9]{40}):(\d+):(\d+):(\d+) -->/u);

if (!previous) {
skip('the existing report has no trusted run identity');

return;
}

if (
previous[1] === source.head &&
(Number(previous[3]) > source.number ||
(Number(previous[3]) === source.number &&
(Number(previous[2]) !== source.run || Number(previous[4]) >= source.attempt)))
) {
skip('an equal or newer report already exists for this head');

return;
}
}

const url = `${context.serverUrl}/${repository.owner}/${repository.repo}`;

const body = [
marker,
`<!-- benchmark-run:${source.head}:${source.run}:${source.number}:${source.attempt} -->`,
'## Benchmark Comparison',
'',
`| Workload | Base, ${unit} | PR, ${unit} | Change |`,
'| --- | ---: | ---: | ---: |',
...rows,
'',
`[Base ${source.base}](${url}/commit/${source.base}) · [PR ${source.head}](${url}/commit/${source.head}) · [Run ${source.number}, attempt ${source.attempt}](${url}/actions/runs/${source.run}/attempts/${source.attempt})`,
].join('\n');

const { data: pr } = await github.rest.pulls.get({
...repository,
pull_number: source.pr,
});

if (pr.state !== 'open' || pr.base.sha !== source.base || pr.head.sha !== source.head) {
skip('PR closed or base/head changed before publication');

return;
}

// GitHub has no compare-and-swap for comments. A PR update can still race this final check and write.
await (comment
? github.rest.issues.updateComment({
...repository,
comment_id: comment.id,
body,
})
: github.rest.issues.createComment({
...repository,
issue_number: source.pr,
body,
}));

await core.summary.addRaw(body).write();
}

async function readJson(file) {
return JSON.parse(await readFile(file, 'utf8'));
}
68 changes: 43 additions & 25 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ jobs:
name: "📊 compare benchmarks"
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
artifact-id: ${{ steps.results.outputs.artifact-id }}
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}

steps:
- name: "📥 check out head"
Expand All @@ -27,32 +31,46 @@ jobs:
- name: "⚙️ set up node and pnpm"
uses: devlsh/tools/github/setup@5a43d10d4535a7cf1368acd9313e70d417ac6235

- name: "📥 check out base"
- name: "📊 compare benchmarks"
id: results
uses: ./.github/actions/benchmark-capture
with:
base-repository: ${{ github.event.pull_request.base.repo.full_name }}
base-sha: ${{ github.event.pull_request.base.sha }}
head-sha: ${{ github.event.pull_request.head.sha }}
capture-command: pnpm bench:full --mode capture --reporter=default
compare-command: pnpm bench:full --mode compare --reporter=default --reporter=github-actions
base-results-path: .vitest/benchmarks/full
head-results-path: .vitest/benchmarks/full/current

report:
name: "📝 report benchmarks"
needs: benchmark
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
needs.benchmark.outputs.artifact-id != '' &&
needs.benchmark.outputs.base != '' &&
needs.benchmark.outputs.head != ''
runs-on: ubuntu-24.04
permissions:
contents: read
issues: write
steps:
- name: "📥 check out publisher"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.event.pull_request.base.sha }}
path: benchmark-base
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
sparse-checkout: |
.github/actions/benchmark-report
.github/scripts/benchmark-report.mjs
sparse-checkout-cone-mode: false

- name: "📥 install base dependencies"
working-directory: benchmark-base
run: pnpm install --frozen-lockfile

- name: "📦 build base"
working-directory: benchmark-base
run: pnpm build

- name: "📂 stage base output"
run: |
mkdir -p dist
cp -R benchmark-base/dist/. dist/

- name: "📊 capture base benchmark"
run: pnpm bench:full --mode capture --reporter=default

- name: "📦 build head"
run: pnpm build

- name: "📊 compare benchmark"
run: pnpm bench:full --mode compare --reporter=default --reporter=github-actions
- name: "📝 report benchmarks"
uses: ./.github/actions/benchmark-report
with:
artifact-id: ${{ needs.benchmark.outputs.artifact-id }}
base-sha: ${{ needs.benchmark.outputs.base }}
head-sha: ${{ needs.benchmark.outputs.head }}
unit: ms/search
operations-per-sample: "4"
Loading
Loading