mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-30 19:46:44 -04:00
* 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.
154 lines
5.7 KiB
Bash
Executable File
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"
|