Files
pnpm/benchmarks/bench.sh
Zoltan Kochan 4088de0433 ci(bencher): record benchmark results to Bencher (#11875)
* ci(bencher): record benchmark results to Bencher

Tracks both stacks in one Bencher project (`pnpm`), under separate
testbeds (`pnpm`, `pacquet`).

- `benchmarks/bench.sh` emits a hyperfine-shaped `bencher-results.json`
  combining the six pnpm scenarios.
- `benchmark.yml` adds `push: branches: [main]` so each merge updates
  the `pnpm` baseline, then uploads via the Bencher CLI.
- `pacquet-integrated-benchmark.yml` adds the same main-push baseline
  for `pacquet`, combines the four scenario JSONs into a Bencher
  report, and stages it into the existing artifact.
- `pacquet-integrated-benchmark-comment.yml` uploads PR results from
  the trusted `workflow_run` context so fork PRs are covered too.

Requires `BENCHER_API_TOKEN` in repo secrets; workflows no-op with a
`::notice::` if it's missing.

* ci(bencher): allow workflow_dispatch to upload pacquet results

The inline Bencher upload was gated to `event_name == 'push'`, which
meant manual dispatch from a feature branch ran the bench but skipped
the upload. Both push and workflow_dispatch execute in the base-repo
privilege context, so it's safe to upload from both — the fork-safety
gate only needs to keep `pull_request` runs out.

On dispatch from a non-main branch we record into the ref name with
`--start-point main --start-point-reset`, matching the pnpm bench's
branch policy.

* ci(bencher): treat workflow_dispatch on main as the main baseline

Two small fixes from PR review:

- `benchmark.yml`: manual dispatch from `main` was falling into the
  non-main branch arm, recording `--branch main --start-point main
  --start-point-reset` — equivalent to forking main from itself.
  Treat it like a `push` event so a manual run on main updates the
  baseline directly. Matches the pacquet workflow's branch policy.
- `bench.sh`: emit a stderr warning when `jq` is missing instead of
  silently skipping `bencher-results.json`. Keeps behaviour optional
  for local users but makes the skip discoverable.

* bench: rename scenarios with explicit state axes

Replaces the hyperfine-leaking names (`clean-install`, `frozen-lockfile`,
`peek`, `gvs-warm`, …) with a consistent grid that spells out every
state the benchmark depends on:

- "Fresh" — node_modules wiped at start (future variants will start
  with a populated node_modules).
- "Install" vs "Restore" vs "Add new dep" — the work being measured.
- "hot/cold cache + hot/cold store" — both pnpm directories,
  spelled out separately because they're distinct on disk.
- "isolated linker" — nodeLinker mode (future variants will cover
  `hoisted` and `pnp`).

The slugs map directly from the clap-derived kebab-case names, so
`--scenario=fresh-restore-cold-cache-cold-store-isolated` is the new
CLI surface. Updates land across the Rust orchestrator
(`BenchmarkScenario`), `benchmarks/bench.sh`, the pacquet workflow,
`benchmarks/README.md`, and `pacquet/CONTRIBUTING.md` so the names
agree end-to-end.

Adds a justified `#[allow(clippy::enum_variant_names)]` on the enum
because every variant currently shares the `Fresh` prefix; the lint
will stop firing once `Filled*`/`Resynced*` counterparts land.

Bencher's stored history for the old benchmark names will become
orphaned and can be archived in the UI.

* bench: linker-first slug shape with dot-separated axes

Reshapes the scenario identifiers so the linker mode is the leading
group: `<linker>.<action>.<cache state>.<store state>`. Dots separate
the four axes the bench varies, and `isolated-linker.*` /
`gvs-linker.*` sort together in any dashboard that groups by prefix.
Future buckets (`hoisted-linker.*`, `pnp-linker.*`) will slot in
without disturbing the existing names.

GVS is its own top-level bucket rather than a sub-variant of
isolated — its perf profile differs enough to chart separately.

Renames:

- `clean-install` → `isolated-linker.fresh-install.cold-cache.cold-store`
- `full-resolution` → `isolated-linker.fresh-install.hot-cache.hot-store`
- `frozen-lockfile` → `isolated-linker.fresh-restore.cold-cache.cold-store`
- `frozen-lockfile-hot-cache` → `isolated-linker.fresh-restore.hot-cache.hot-store`
- `peek` → `isolated-linker.fresh-add-dep.hot-cache.hot-store`
- `gvs-warm` → `gvs-linker.fresh-restore.hot-cache.hot-store`

Each Rust variant now carries `#[value(name = "…")]` so clap accepts
the dotted CLI form (`--scenario=isolated-linker.fresh-install.cold-cache.cold-store`).

Display labels follow the slug structure: `Isolated linker: fresh
install, cold cache + cold store` and `GVS linker: fresh restore,
hot cache + hot store`.

The `#[allow(clippy::enum_variant_names)]` is renewed; 5 of 6 variants
share the `Isolated` prefix today. Once `Hoisted*` / `Pnp*` buckets
land the lint will stop firing on its own.

* style: apply rustfmt after scenario rename

The longer match-arm pattern produced by the linker-first rename
exceeded the rustfmt width budget. Auto-format breaks the
`&["install", "--frozen-lockfile"]` body onto its own line so the
arm stays within the limit.
2026-05-23 15:19:49 +02:00

154 lines
5.7 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
# Thin wrapper around `pacquet/tasks/integrated-benchmark`. Builds two
# pnpm revisions (the current branch and `main`) and runs hyperfine for
# each of the six scenarios that used to live in this script.
#
# Scenarios, registry choice, and runner behaviour are preserved exactly
# as before; the orchestration logic is shared with the pacquet bench.
#
# Prerequisites: cargo, hyperfine, pnpm, node, git.
#
# Env vars: WARMUP (default 1), RUNS (default 10).
#
# Usage: ./benchmarks/bench.sh
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FIXTURE_DIR="$REPO_ROOT/benchmarks/fixture"
WARMUP="${WARMUP:-1}"
RUNS="${RUNS:-10}"
BENCH_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pnpm-bench.XXXXXX")"
for tool in cargo hyperfine pnpm node git; do
if ! command -v "$tool" >/dev/null; then
echo "error: $tool not on PATH" >&2
exit 1
fi
done
echo "── Building integrated-benchmark ──"
cargo build --release --bin=integrated-benchmark --manifest-path "$REPO_ROOT/Cargo.toml"
BIN="$REPO_ROOT/target/release/integrated-benchmark"
# Ensure `pnpm@main` resolves locally. `actions/checkout` only creates a
# local ref for the branch it checked out; on a workflow_dispatch run from
# a non-main branch (or after the optional PR-head checkout in
# `benchmark.yml`) there's no `refs/heads/main` for `git rev-parse` to
# hit. Skip the fetch entirely when the local ref already exists, and
# let the fetch surface its real error if it fails.
if ! git -C "$REPO_ROOT" rev-parse --verify --quiet refs/heads/main >/dev/null; then
echo "── Fetching main into local ref ──"
git -C "$REPO_ROOT" fetch --no-tags origin main:main
fi
# Scenario list: `slug:Display label`. The slug matches the
# orchestrator's `--scenario` value (the clap-derived kebab-case name
# from `BenchmarkScenario`). All six start with `node_modules` wiped
# — "Fresh" names that target state. "Isolated linker" names the
# `nodeLinker` mode; alternatives (`hoisted`, `pnp`) and populated-
# node_modules counterparts are reserved for future scenarios.
SCENARIOS=(
"isolated-linker.fresh-restore.hot-cache.hot-store:Isolated linker: fresh restore, hot cache + hot store"
"isolated-linker.fresh-add-dep.hot-cache.hot-store:Isolated linker: fresh add new dep, hot cache + hot store"
"isolated-linker.fresh-install.hot-cache.hot-store:Isolated linker: fresh install, hot cache + hot store"
"isolated-linker.fresh-restore.cold-cache.cold-store:Isolated linker: fresh restore, cold cache + cold store"
"isolated-linker.fresh-install.cold-cache.cold-store:Isolated linker: fresh install, cold cache + cold store"
"gvs-linker.fresh-restore.hot-cache.hot-store:GVS linker: fresh restore, hot cache + hot store"
)
# Pre-build both revisions once. Subsequent scenario invocations still
# re-enter the orchestrator's build step (sync_bench_repo + pnpm install
# + pnpm run compile), but `pnpm install` is a no-op on the populated
# node_modules and `tsgo --build` is incremental. `pnpm run bundle`
# (which produces pnpm/dist/pnpm.mjs) does run each time and is not
# incremental — accepted overhead in exchange for keeping the build
# path in one consistent place across pacquet and pnpm benches.
echo "── Pre-building pnpm revisions ──"
"$BIN" \
--pnpm-repository "$REPO_ROOT" \
--work-env "$BENCH_DIR/work-env" \
--build-only \
pnpm@HEAD pnpm@main
# Pull mean ± stddev for each variant out of a hyperfine JSON into one
# table cell. Falls back to "n/a" if jq isn't on PATH, the file is
# missing, or the target isn't present in the JSON.
read_cell() {
local target=$1
local json=$2
if ! command -v jq >/dev/null; then
echo "n/a"
return
fi
jq -r --arg t "$target" '
[.results[] | select(.command == $t)
| "\((.mean*1000|round)/1000)s ± \((.stddev*1000|round)/1000)s"]
| first // "n/a"
' "$json" 2>/dev/null || echo "n/a"
}
results_md="$BENCH_DIR/results.md"
{
echo "# Benchmark Results"
echo
echo "| # | Scenario | main | HEAD |"
echo "|---|---|---|---|"
} > "$results_md"
i=1
for entry in "${SCENARIOS[@]}"; do
scenario="${entry%%:*}"
label="${entry#*:}"
echo ""
echo "━━━ Benchmark $i: $label ━━━"
"$BIN" \
--scenario "$scenario" \
--registry npm \
--pnpm-repository "$REPO_ROOT" \
--fixture-dir "$FIXTURE_DIR" \
--work-env "$BENCH_DIR/work-env" \
--warmup "$WARMUP" \
--runs "$RUNS" \
--ignore-failure \
pnpm@main pnpm@HEAD
cp "$BENCH_DIR/work-env/BENCHMARK_REPORT.md" "$BENCH_DIR/${scenario}.md"
cp "$BENCH_DIR/work-env/BENCHMARK_REPORT.json" "$BENCH_DIR/${scenario}.json"
main_cell=$(read_cell "pnpm@main" "$BENCH_DIR/${scenario}.json")
head_cell=$(read_cell "pnpm@HEAD" "$BENCH_DIR/${scenario}.json")
echo "| $i | $label | $main_cell | $head_cell |" >> "$results_md"
i=$((i + 1))
done
# Combine the per-scenario hyperfine JSONs into one Bencher-shaped
# report. Keep only the @HEAD result from each scenario and rename
# `.command` to the scenario name so Bencher's shell_hyperfine adapter
# names the benchmark after the scenario instead of `pnpm@HEAD`.
if command -v jq >/dev/null; then
bencher_inputs=()
for entry in "${SCENARIOS[@]}"; do
scenario="${entry%%:*}"
jq --arg s "$scenario" \
'.results |= [.[] | select(.command == "pnpm@HEAD") | .command = $s]' \
"$BENCH_DIR/${scenario}.json" > "$BENCH_DIR/${scenario}-bencher.json"
bencher_inputs+=("$BENCH_DIR/${scenario}-bencher.json")
done
jq -s '{results: map(.results) | add}' \
"${bencher_inputs[@]}" > "$BENCH_DIR/bencher-results.json"
else
echo "warning: jq not on PATH; skipping bencher-results.json generation" >&2
fi
echo
echo "━━━ Results ━━━"
cat "$results_md"
echo
echo "Results saved to: $results_md"
echo "Temp directory kept at: $BENCH_DIR"
echo "Remove with: rm -rf $BENCH_DIR"