Files
pulse/scripts/run-ci-benchmarks.sh
T
rcourtman b027bb0e8e Keep paired PR benchmarks inside their time budget
Since 15:00 UTC on 5 October every paired benchmark run that was not
superseded has hit its 25-minute step timeout. Each step runs ten base
and ten candidate samples, so per-sample growth is multiplied by twenty.

BenchmarkRegistryProjectionSnapshot (#2518) rebuilt a registry behind
StopTimer on every cold iteration. Go sizes b.N from timed work only, so
the empty-registry cases ran hundreds of thousands of untimed ingests
and took 16 s of a 100 ms benchtime. With #2486's new owner-index and
metadata benchmarks, internal/unifiedresources went from 8 s to 38 s per
sample. Cold now invalidates the registry's derived state instead. The
refresh never reads its own output, so this repeats a fresh ingest's
first read with the same allocations, and the family drops from 22 s to
about 5 s per sample.

Paired runs also measured every package even when both trees compiled
identical code, as on frontend-only changes. They now sample only
packages whose in-module dependency closure the change reaches. Module
files, dirty or non-Git trees and an unreadable graph still select
everything.

A module or shared-model change still samples nearly every package,
about 24 minutes with warm-up. The step therefore gets a measured 30
minutes, and the script stops at a 28-minute projected budget with
per-package costs instead of meeting a bare timeout.
2026-10-05 22:39:51 +01:00

270 lines
9.3 KiB
Bash
Executable File

#!/usr/bin/env bash
# Run the CI benchmark sample. When a baseline tree is supplied, execute the
# base and candidate on the same host in alternating order so host-to-host
# variance and run order are not mistaken for code regressions.
set -euo pipefail
CURRENT_DIR="${PULSE_BENCH_CURRENT_DIR:-$(pwd)}"
BASELINE_DIR="${PULSE_BENCH_BASELINE_DIR:-}"
SAMPLE_COUNT="${PULSE_BENCH_SAMPLE_COUNT:-10}"
BENCHTIME="${PULSE_BENCH_BENCHTIME:-100ms}"
# Wall-clock seconds a paired run may take, warm-up included; unset means no
# limit. Checked after every round, so a suite that has outgrown its budget
# stops early and names each package's cost instead of meeting a step timeout.
BUDGET_SECONDS="${PULSE_BENCH_BUDGET_SECONDS:-}"
if ! [[ "${SAMPLE_COUNT}" =~ ^[1-9][0-9]*$ ]]; then
echo "PULSE_BENCH_SAMPLE_COUNT must be a positive integer." >&2
exit 2
fi
if [[ -n "${BUDGET_SECONDS}" && ! "${BUDGET_SECONDS}" =~ ^[0-9]+$ ]]; then
echo "PULSE_BENCH_BUDGET_SECONDS must be a whole number of seconds." >&2
exit 2
fi
if [[ ! -d "${CURRENT_DIR}" ]]; then
echo "Benchmark candidate tree not found: ${CURRENT_DIR}" >&2
exit 2
fi
if [[ -n "${BASELINE_DIR}" && ! -d "${BASELINE_DIR}" ]]; then
echo "Benchmark baseline tree not found: ${BASELINE_DIR}" >&2
exit 2
fi
PACKAGES=(
./pkg/metrics/
./pkg/auth/
./internal/api/
./internal/monitoring/
./internal/unifiedresources/
./internal/dockeragent/
./cmd/pulse-agent/
./internal/hostagent/
./internal/hostmetrics/
)
work_dir="$(mktemp -d)"
trap 'rm -rf "${work_dir}"' EXIT
# An archived tree nested inside another checkout must not inherit its SHA.
is_git_root() {
local prefix
prefix="$(git -C "$1" rev-parse --show-prefix 2>/dev/null)" && [[ -z "${prefix}" ]]
}
# Paths whose committed content differs between the two trees. Uncommitted
# edits to tracked files would be measured but not seen, so they fail.
changed_paths() {
local label tree
for label in baseline candidate; do
tree="${CURRENT_DIR}"
[[ "${label}" != baseline ]] || tree="${BASELINE_DIR}"
is_git_root "${tree}" || return 1
[[ -z "$(git -C "${tree}" status --porcelain --untracked-files=no)" ]] || return 1
git -C "${tree}" ls-tree -r --full-tree HEAD | LC_ALL=C sort > "${work_dir}/${label}.files" ||
return 1
done
LC_ALL=C comm -3 "${work_dir}/baseline.files" "${work_dir}/candidate.files" |
awk -F '\t' '{ print $NF }' | LC_ALL=C sort -u
}
# Exit 0 when a changed path reaches the package's benchmarks, 1 when none
# does, and 2 when the package graph cannot be read. A test file compiles only
# into its own package's test binary; any other file reaches every package
# that imports its directory, including through embeds and testdata below it.
package_affected() {
local package="$1" changed="$2" closure="${work_dir}/closure" root
root="$(cd "${CURRENT_DIR}" && go list -m -f '{{.Dir}}')" || return 2
(cd "${CURRENT_DIR}" && go list -e -deps -test \
-f '{{with .Module}}{{if .Main}}{{$.Dir}}{{end}}{{end}}' "${package}") > "${closure}" ||
return 2
[[ -s "${closure}" ]] || return 2
package="${package#./}"
awk -v root="${root}" -v own="${package%/}" '
FILENAME == ARGV[1] {
if ($0 == root) dirs["."]
else if (index($0, root "/") == 1) dirs[substr($0, length(root) + 2)]
next
}
/_test\.go$/ {
dir = $0
if (!sub(/\/[^\/]*$/, "", dir)) dir = "."
if (dir == own) { hit = 1; exit }
next
}
{
if ("." in dirs) { hit = 1; exit }
dir = $0
while (sub(/\/[^\/]*$/, "", dir)) if (dir in dirs) { hit = 1; exit }
}
END { exit hit ? 0 : 1 }
' "${closure}" "${changed}"
}
# A paired run measures only packages whose in-module dependency closure the
# candidate changes. Elsewhere both trees compile identical code, so samples
# add runner time and noise but no evidence. Module files, or trees or a
# package graph that cannot be read, select every package.
SELECTED=("${PACKAGES[@]}")
selection="all (no baseline)"
select_packages() {
local changed="${work_dir}/changed-paths" package status picked=()
if ! changed_paths > "${changed}"; then
selection="all (trees are not both clean Git checkouts)"
return
fi
if grep -qxE 'go\.(mod|sum|work|work\.sum)' "${changed}"; then
selection="all (module files changed)"
return
fi
for package in "${PACKAGES[@]}"; do
status=0
package_affected "${package}" "${changed}" || status=$?
case "${status}" in
0) picked+=("${package}") ;;
1) ;;
*)
selection="all (package graph unreadable)"
return
;;
esac
done
SELECTED=(${picked[@]+"${picked[@]}"})
selection="affected"
}
if [[ -n "${BASELINE_DIR}" ]]; then
select_packages
echo "Benchmark packages, ${selection}: ${SELECTED[*]:-none}"
fi
# Retain provenance without dumping credentials, remote URLs or working paths.
# PR checkouts may be synthetic merges; Go selects a toolchain for each tree.
metadata="${CURRENT_DIR}/bench-metadata.txt"
revision() {
is_git_root "$1" || { echo unavailable; return; }
git -C "$1" rev-parse --verify "$2" 2>/dev/null || echo unavailable
}
{
printf 'started_at=%s\nsamples=%s\nbenchtime=%s\n' \
"$(date -u +%FT%TZ)" "${SAMPLE_COUNT}" "${BENCHTIME}"
printf 'platform=%s\n' "$(uname -sm)"
printf 'gomaxprocs=%s\n' "${GOMAXPROCS:-runtime-default}"
printf 'selection=%s\n' "${selection}"
printf 'packages=%s\n' "${SELECTED[*]:-}"
for label in candidate baseline; do
tree="${CURRENT_DIR}"
[[ "${label}" != baseline ]] || tree="${BASELINE_DIR}"
[[ -n "${tree}" ]] || continue
printf '%s.commit=%s\n' "${label}" "$(revision "${tree}" HEAD)"
printf '%s.tree=%s\n' "${label}" "$(revision "${tree}" 'HEAD^{tree}')"
printf '%s.go=%s\n' "${label}" "$(cd "${tree}" && go version)"
done
} > "${metadata}"
# Hash the actual executable Go is about to run, not a later reconstruction.
# Keep hashing outside benchmark timing and never log binary paths or arguments.
binary_wrapper="${work_dir}/record-binary"
cat > "${binary_wrapper}" <<'WRAPPER'
#!/usr/bin/env bash
set -euo pipefail
digest="$(sha256sum -- "$1")"
digest="${digest%% *}"
printf 'binary=%s,%s,%s,%s\n' "${PULSE_BENCH_LABEL}" \
"${PULSE_BENCH_ROUND}" "${1##*/}" "${digest}" >> "${PULSE_BENCH_METADATA}"
exec "$@"
WRAPPER
chmod +x "${binary_wrapper}"
export PULSE_BENCH_METADATA="${metadata}"
run_sample() {
local tree="$1"
local output="$2"
local label="$3"
local round="$4"
local data_dir="${work_dir}/${label}-${round}"
mkdir -p "${data_dir}"
printf 'sample=%s,%s,%s\n' "${label}" "${round}" "$(date -u +%FT%TZ)" >> "${metadata}"
echo "=== ${label} benchmark sample ${round}/${SAMPLE_COUNT} ==="
(
cd "${tree}"
PULSE_BENCH_LABEL="${label}" PULSE_BENCH_ROUND="${round}" \
PULSE_DATA_DIR="${data_dir}" go test -exec "${binary_wrapper}" \
-bench=. -benchmem -count=1 -run='^$' \
-benchtime="${BENCHTIME}" -timeout=5m \
"${SELECTED[@]}"
) | tee -a "${output}"
}
run_unpaired() {
local output="${CURRENT_DIR}/bench-results.txt"
local data_dir="${work_dir}/candidate"
mkdir -p "${data_dir}"
(
cd "${CURRENT_DIR}"
PULSE_BENCH_LABEL=candidate PULSE_BENCH_ROUND=unpaired \
PULSE_DATA_DIR="${data_dir}" go test -exec "${binary_wrapper}" \
-bench=. -benchmem -count="${SAMPLE_COUNT}" -run='^$' \
-benchtime="${BENCHTIME}" -timeout=5m \
"${PACKAGES[@]}"
) | tee "${output}"
}
if [[ -z "${BASELINE_DIR}" ]]; then
run_unpaired
exit 0
fi
baseline_output="${CURRENT_DIR}/bench-baseline.txt"
candidate_output="${CURRENT_DIR}/bench-results.txt"
: > "${baseline_output}"
: > "${candidate_output}"
if ((${#SELECTED[@]} == 0)); then
echo "No benchmarked package depends on a changed file; nothing to compare."
exit 0
fi
# Project the whole run from the rounds measured so far. Stop once it would
# reach the budget, naming each package's mean seconds per sample.
check_budget() {
local round="$1" elapsed="${SECONDS}" projected
[[ -n "${BUDGET_SECONDS}" ]] || return 0
((round < SAMPLE_COUNT)) || return 0
projected=$((elapsed + (elapsed - sampling_started) * (SAMPLE_COUNT - round) / round))
((projected >= BUDGET_SECONDS)) || return 0
{
echo "Benchmark budget exceeded: ${round} of ${SAMPLE_COUNT} paired rounds took ${elapsed}s" \
"including warm-up, projecting ${projected}s against a ${BUDGET_SECONDS}s budget."
echo "Mean seconds per sample by package:"
awk '$1 == "ok" { total[$2] += $3; count[$2]++ }
END { for (p in total) printf "%8.1f %s\n", total[p] / count[p], p }' \
"${baseline_output}" "${candidate_output}" | sort -rn
} >&2
exit 1
}
# Populate build and filesystem caches without adding a timed sample. This
# avoids consistently charging the first measured tree for cold setup.
for tree in "${BASELINE_DIR}" "${CURRENT_DIR}"; do
(
cd "${tree}"
PULSE_DATA_DIR="${work_dir}/warmup" go test \
-bench=. -count=1 -run='^$' -benchtime=1x -timeout=5m \
"${SELECTED[@]}" >/dev/null
)
done
sampling_started="${SECONDS}"
for ((round = 1; round <= SAMPLE_COUNT; round++)); do
if ((round % 2 == 1)); then
run_sample "${BASELINE_DIR}" "${baseline_output}" baseline "${round}"
run_sample "${CURRENT_DIR}" "${candidate_output}" candidate "${round}"
else
run_sample "${CURRENT_DIR}" "${candidate_output}" candidate "${round}"
run_sample "${BASELINE_DIR}" "${baseline_output}" baseline "${round}"
fi
check_budget "${round}"
done