Files
rustfs/scripts/run_hotpath_warp_ab.sh
T
houseme 2157470224 ci(perf): warp A/B relative-budget gate for the hotpath series (#4480)
* ci(perf): warp A/B relative-budget gate for the hotpath series

performance.yml only uploads a samply profile and a cargo-bench artifact with
no pass/fail, so a 26x-class write-path regression like #4221 or the GET perf
churn would sail through CI and only surface in customer load tests. This adds
the missing gate — the acceptance surface every queued HP change (#922/#923/
#925/#927/#930/#932) needs before its default can flip.

- scripts/hotpath_warp_ab_gate.sh: applies the relative budget to the
  baseline_compare.csv that run_object_batch_bench_enhanced.sh already emits
  (it computes deltas but never gates). A metric regressing past --fail-pct
  fails, past --warn-pct warns; reqps/throughput are higher-is-better, latency
  lower-is-better. --allow-regression downgrades a FAIL to an exempted WARN so
  a deliberate correctness cost (e.g. #4221) is recorded, not blocked
  (rustfs/backlog#935 correction 1). Unit-checked across pass/warn/fail/exempt.
- scripts/run_hotpath_warp_ab.sh: single-host baseline-binary vs candidate-
  binary A/B over {put-4mib, get-4mib, mixed-256k} x {drive-sync on, off},
  reusing the enhanced bench as the warp driver and the single-node local-disk
  lifecycle. Has --dry-run; warp is assumed pre-installed as elsewhere in
  scripts/. Drive-sync on/off keeps a sync-semantics change from being masked
  by nosync numbers.
- .github/workflows/performance-ab.yml: nightly on main (post-merge detection)
  plus opt-in pre-merge via the `perf-ab` label; `perf-deliberate-tradeoff`
  runs the gate with --allow-regression; posts the gate table as a PR comment
  and uploads the run. A Linux runner answers "do the macOS conclusions hold".

The gate logic is unit-validated with synthetic compare CSVs; the orchestrator
and workflow are shellcheck- and --dry-run-validated. The first real warp
measurement belongs on the Linux runner (no warp/multi-disk rig locally).

Refs: rustfs/backlog#935 (HP-14 warp A/B gate, item 4), rustfs/backlog#725
(cooled A/B harness precedent), rustfs/backlog#936

Co-Authored-By: heihutu <heihutu@gmail.com>

* ci(perf): pin upload-artifact, add external-cluster A/B mode + runbook

- Pin actions/upload-artifact to the repo-standard full-length SHA (# v6);
  the previous @v4 float tripped the workflow-pin guard.
- Add an external deployment mode to run_hotpath_warp_ab.sh so warp can target
  an already-running cluster instead of a throwaway single-node server:
  --endpoint selects the cluster and --deploy-hook runs between phases to swap
  in the phase's binary and drive-sync config (context passed via
  HOTPATH_AB_PHASE / HOTPATH_AB_BINARY / HOTPATH_AB_DRIVE_SYNC). This maps onto
  the team's ansible harness (cargo zigbuild -> ansible rustfs-manage --tags
  stop,config,binary-copy,start -> warp).
- Restructure the matrix loop to bring a deployment up once per (phase,
  drive-sync) and run all three workloads against it, instead of restarting per
  workload — fewer server starts / cluster redeploys. Baseline medians are read
  from a deterministic path, dropping the bash-4-only associative array so the
  script (and its --dry-run self-check) runs on macOS bash 3.2 too.
- Add docs/operations/hotpath-warp-ab-runbook.md tying local mode, the ansible
  external mode, and the budget/exemption together.

Verification: shellcheck clean; --dry-run in both modes prints the expected
4 deployments x 3 workloads and passes exactly 6 compare CSVs to the gate;
check_workflow_pins.sh, check_doc_paths.sh, and make pre-commit all pass.

Refs: rustfs/backlog#935, rustfs/backlog#936

Co-Authored-By: heihutu <heihutu@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-07-08 13:22:44 +00:00

304 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# run_hotpath_warp_ab.sh — Linux warp A/B rig for the hotpath series
# (rustfs/backlog#935 HP-14, item 4).
#
# Runs the same warp workloads against a baseline binary and a candidate
# binary, across the drive-sync on/off matrix, then applies the relative-budget
# gate (hotpath_warp_ab_gate.sh). This is how the macOS profiling conclusions
# of the HP series get confirmed or corrected on Linux: structural wins (call
# counts, read amplification) should hold; absolute numbers are whatever this
# rig measures.
#
# Two deployment modes:
# * local (default): build both binaries and run a throwaway single-node
# server on local disks — good for a quick or CI smoke A/B.
# * external (--endpoint <host:port>): warp targets an already-running
# cluster; a --deploy-hook command swaps in the right binary / durability
# config between phases. This maps onto the team's ansible harness
# (cargo zigbuild -> ansible rustfs-install / rustfs-manage --tags
# stop,config,binary-copy,start -> warp). See
# docs/operations/hotpath-warp-ab-runbook.md.
#
# Load generation and result parsing are delegated to the existing
# scripts/run_object_batch_bench_enhanced.sh (warp driver + median +
# baseline_compare deltas). warp is assumed pre-installed, as elsewhere in
# scripts/.
#
# The rig is shellcheck- and --dry-run-validated; its first real measurement
# run belongs on a Linux runner / cluster with warp installed.
set -euo pipefail
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
ENHANCED_BENCH="${PROJECT_ROOT}/scripts/run_object_batch_bench_enhanced.sh"
GATE="${PROJECT_ROOT}/scripts/hotpath_warp_ab_gate.sh"
# --- Configuration (overridable via flags) ----------------------------------
BASELINE_REF="origin/main"
BASELINE_BIN=""
CANDIDATE_BIN=""
DISKS=4
CONCURRENCY=8
DURATION="30s"
ROUNDS=3
LOCAL_ADDRESS="127.0.0.1:9000"
ACCESS_KEY="rustfsadmin"
SECRET_KEY="rustfsadmin"
REGION="us-east-1"
WARP_BIN="warp"
FAIL_PCT=10
WARN_PCT=5
ALLOW_REGRESSION="false"
EXEMPTION_REASON="deliberate correctness tradeoff"
DATA_ROOT="/tmp/rustfs-hotpath-ab"
OUT_DIR="${PROJECT_ROOT}/target/hotpath-ab/$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || echo run)"
DRY_RUN="false"
SKIP_BUILD="false"
EXTERNAL_ENDPOINT="" # set -> external mode (warp targets this cluster)
DEPLOY_HOOK="" # external mode: command to deploy a phase's binary
HEALTH_PATH="/health"
# Workloads: name|warp-mode|size. put-4mib obj/s, get-4mib MiB/s, mixed-256k p99.
WORKLOADS=("put-4mib|put|4MiB" "get-4mib|get|4MiB" "mixed-256k|mixed|256KiB")
# Durability matrix: label|RUSTFS_DRIVE_SYNC_ENABLE value.
DRIVE_SYNC_MATRIX=("sync-on|true" "sync-off|false")
usage() {
cat <<'USAGE'
Usage: run_hotpath_warp_ab.sh [options]
Local mode (default): build + run a throwaway single-node server.
--baseline-ref <ref> Git ref to build the baseline from (default origin/main).
--baseline-bin <path> Prebuilt baseline binary (skips building the ref).
--candidate-bin <path> Prebuilt candidate binary (default: build the worktree).
--disks <n> Local disks for the single node (default 4).
--skip-build Do not build; requires --baseline-bin/--candidate-bin.
External mode: warp targets an already-running cluster (e.g. ansible-deployed).
--endpoint <host:port> Cluster S3 endpoint; enables external mode.
--deploy-hook <cmd> Command run before each phase to deploy that phase's
binary and durability config. It receives context via
env: HOTPATH_AB_PHASE (baseline|candidate),
HOTPATH_AB_BINARY (path or empty),
HOTPATH_AB_DRIVE_SYNC (true|false). Non-zero aborts.
--health-path <path> Readiness path polled after deploy (default /health).
Common:
--concurrency <n> warp --concurrent (default 8).
--duration <dur> warp --duration (default 30s).
--rounds <n> Rounds per cell for the median (default 3).
--fail-pct <n> Gate fail budget (default 10).
--warn-pct <n> Gate warn budget (default 5).
--allow-regression Pass the gate despite a FAIL (deliberate tradeoff).
--exemption-reason <s> Reason recorded with --allow-regression.
--out-dir <path> Output directory (default target/hotpath-ab/<ts>).
--dry-run Print the plan and commands without running them.
-h, --help Show this help.
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--baseline-ref) BASELINE_REF="$2"; shift 2 ;;
--baseline-bin) BASELINE_BIN="$2"; shift 2 ;;
--candidate-bin) CANDIDATE_BIN="$2"; shift 2 ;;
--disks) DISKS="$2"; shift 2 ;;
--endpoint) EXTERNAL_ENDPOINT="$2"; shift 2 ;;
--deploy-hook) DEPLOY_HOOK="$2"; shift 2 ;;
--health-path) HEALTH_PATH="$2"; shift 2 ;;
--concurrency) CONCURRENCY="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--rounds) ROUNDS="$2"; shift 2 ;;
--fail-pct) FAIL_PCT="$2"; shift 2 ;;
--warn-pct) WARN_PCT="$2"; shift 2 ;;
--allow-regression) ALLOW_REGRESSION="true"; shift ;;
--exemption-reason) EXEMPTION_REASON="$2"; shift 2 ;;
--out-dir) OUT_DIR="$2"; shift 2 ;;
--skip-build) SKIP_BUILD="true"; shift ;;
--dry-run) DRY_RUN="true"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
EXTERNAL="false"
[[ -n "$EXTERNAL_ENDPOINT" ]] && EXTERNAL="true"
ADDRESS="$EXTERNAL_ENDPOINT"
[[ "$EXTERNAL" == "true" ]] || ADDRESS="$LOCAL_ADDRESS"
log() { printf '[hotpath-ab] %s\n' "$*" >&2; }
run() {
# DRY-RUN lines go to stderr so functions that return a value on stdout stay
# uncontaminated.
if [[ "$DRY_RUN" == "true" ]]; then
{ printf 'DRY-RUN:'; printf ' %q' "$@"; printf '\n'; } >&2
return 0
fi
"$@"
}
[[ -x "$ENHANCED_BENCH" ]] || { echo "missing load driver: $ENHANCED_BENCH" >&2; exit 2; }
[[ -x "$GATE" ]] || { echo "missing gate: $GATE" >&2; exit 2; }
if [[ "$DRY_RUN" != "true" ]] && ! command -v "$WARP_BIN" >/dev/null 2>&1; then
echo "error: warp not found on PATH (install warp or use --dry-run)" >&2
exit 2
fi
if [[ "$EXTERNAL" == "true" && -z "$DEPLOY_HOOK" ]]; then
log "warning: external mode without --deploy-hook — baseline and candidate must be swapped out of band"
fi
mkdir -p "$OUT_DIR"
log "mode: $([[ "$EXTERNAL" == "true" ]] && echo "external ($ADDRESS)" || echo "local ($ADDRESS)")"
log "output dir: $OUT_DIR"
# --- Build binaries (local mode only) ---------------------------------------
build_ref_binary() {
local ref="$1" dest="$OUT_DIR/rustfs-baseline"
if [[ -n "$BASELINE_BIN" ]]; then echo "$BASELINE_BIN"; return; fi
local wt="$OUT_DIR/baseline-worktree"
run git worktree add --detach "$wt" "$ref"
run bash -c "cd '$wt' && cargo build --release --bin rustfs"
run cp "$wt/target/release/rustfs" "$dest" 2>/dev/null || true
run git worktree remove --force "$wt" 2>/dev/null || true
echo "$dest"
}
if [[ "$EXTERNAL" == "true" ]]; then
# Binaries are optional context handed to the deploy hook.
log "baseline binary: ${BASELINE_BIN:-<managed by deploy hook>}"
log "candidate binary: ${CANDIDATE_BIN:-<managed by deploy hook>}"
elif [[ "$SKIP_BUILD" == "true" ]]; then
[[ -n "$BASELINE_BIN" && -n "$CANDIDATE_BIN" ]] || {
echo "error: --skip-build requires --baseline-bin and --candidate-bin" >&2; exit 2; }
else
[[ -n "$CANDIDATE_BIN" ]] || {
log "building candidate (current worktree)"
run cargo build --release --bin rustfs
CANDIDATE_BIN="${PROJECT_ROOT}/target/release/rustfs"
}
[[ -n "$BASELINE_BIN" ]] || {
log "building baseline ($BASELINE_REF)"
BASELINE_BIN="$(build_ref_binary "$BASELINE_REF")"
}
fi
# --- Deployment: bring a phase up, tear it down -----------------------------
SERVER_PID=""
tear_down() {
# Local mode owns the server process; external mode leaves lifecycle to the
# deploy hook / cluster orchestrator.
[[ "$EXTERNAL" == "true" ]] && return 0
[[ -n "$SERVER_PID" ]] || return 0
run kill "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
}
trap tear_down EXIT INT TERM
wait_health() {
[[ "$DRY_RUN" == "true" ]] && return 0
local i
for ((i = 0; i < 60; i++)); do
if curl -fsS "http://${ADDRESS}${HEALTH_PATH}" >/dev/null 2>&1; then return 0; fi
sleep 1
done
echo "error: endpoint http://${ADDRESS}${HEALTH_PATH} did not become healthy" >&2
return 1
}
bring_up() {
# bring_up <phase> <binary> <drive_sync>
local phase="$1" bin="$2" drive_sync="$3"
if [[ "$EXTERNAL" == "true" ]]; then
if [[ -n "$DEPLOY_HOOK" ]]; then
log "deploy hook: phase=$phase drive_sync=$drive_sync"
HOTPATH_AB_PHASE="$phase" HOTPATH_AB_BINARY="$bin" HOTPATH_AB_DRIVE_SYNC="$drive_sync" \
run bash -c "$DEPLOY_HOOK"
fi
wait_health
return 0
fi
# Local: throwaway single-node server on fresh disks.
local node_dir="$DATA_ROOT/$phase-sync-$drive_sync"
local disks=() d
for ((d = 1; d <= DISKS; d++)); do disks+=("$node_dir/d$d"); done
run mkdir -p "${disks[@]}"
if [[ "$DRY_RUN" == "true" ]]; then
{ printf 'DRY-RUN: RUSTFS_DRIVE_SYNC_ENABLE=%s %q server' "$drive_sync" "$bin"
printf ' %q' "${disks[@]}"; printf '\n'; } >&2
SERVER_PID="dry-run"
return 0
fi
RUSTFS_UNSAFE_BYPASS_DISK_CHECK=true \
RUSTFS_ADDRESS="$ADDRESS" \
RUSTFS_ACCESS_KEY="$ACCESS_KEY" \
RUSTFS_SECRET_KEY="$SECRET_KEY" \
RUSTFS_REGION="$REGION" \
RUSTFS_CONSOLE_ENABLE=false \
RUSTFS_DRIVE_SYNC_ENABLE="$drive_sync" \
"$bin" server "${disks[@]}" >"$node_dir/rustfs.log" 2>&1 &
SERVER_PID=$!
wait_health
}
# --- Measure one workload against the current deployment --------------------
measure() {
# measure <phase> <workload> <mode> <size> <sync_label> [baseline_csv]
local phase="$1" wl="$2" mode="$3" size="$4" sync_label="$5" baseline_csv="${6:-}"
local cell="$OUT_DIR/$wl/$sync_label/$phase"
run mkdir -p "$cell"
local args=(
--tool warp --warp-bin "$WARP_BIN" --warp-mode "$mode"
--endpoint "$ADDRESS" --access-key "$ACCESS_KEY" --secret-key "$SECRET_KEY"
--region "$REGION" --sizes "$size" --concurrency "$CONCURRENCY"
--duration "$DURATION" --rounds "$ROUNDS" --out-dir "$cell"
)
[[ -n "$baseline_csv" ]] && args+=(--baseline-csv "$baseline_csv")
run "$ENHANCED_BENCH" "${args[@]}"
echo "$cell"
}
# --- Drive the matrix: per drive-sync, baseline phase then candidate phase ---
declare -a COMPARE_CSVS=()
for ds_spec in "${DRIVE_SYNC_MATRIX[@]}"; do
IFS='|' read -r sync_label drive_sync <<<"$ds_spec"
log "=== drive-sync $sync_label: baseline phase ==="
bring_up "baseline" "$BASELINE_BIN" "$drive_sync"
for wl_spec in "${WORKLOADS[@]}"; do
IFS='|' read -r wl mode size <<<"$wl_spec"
measure "baseline" "$wl" "$mode" "$size" "$sync_label" >/dev/null
done
tear_down
log "=== drive-sync $sync_label: candidate phase (vs baseline) ==="
bring_up "candidate" "$CANDIDATE_BIN" "$drive_sync"
for wl_spec in "${WORKLOADS[@]}"; do
IFS='|' read -r wl mode size <<<"$wl_spec"
# measure() writes each phase under $OUT_DIR/<wl>/<sync>/<phase>, so the
# baseline median for this cell is at a deterministic path.
baseline_csv="$OUT_DIR/$wl/$sync_label/baseline/median_summary.csv"
cand_cell="$(measure "candidate" "$wl" "$mode" "$size" "$sync_label" "$baseline_csv")"
COMPARE_CSVS+=("$cand_cell/baseline_compare.csv")
done
tear_down
done
# --- Gate -------------------------------------------------------------------
gate_args=(--fail-pct "$FAIL_PCT" --warn-pct "$WARN_PCT" --markdown "$OUT_DIR/gate.md")
for csv in "${COMPARE_CSVS[@]}"; do gate_args+=(--compare-csv "$csv"); done
[[ "$ALLOW_REGRESSION" == "true" ]] && gate_args+=(--allow-regression --exemption-reason "$EXEMPTION_REASON")
if [[ "$DRY_RUN" == "true" ]]; then
log "dry-run complete; would gate on ${#COMPARE_CSVS[@]} compare CSV(s)"
{ printf 'DRY-RUN:'; printf ' %q' "$GATE" "${gate_args[@]}"; printf '\n'; } >&2
exit 0
fi
log "applying relative-budget gate"
"$GATE" "${gate_args[@]}"
gate_status=$?
log "gate result written to $OUT_DIR/gate.md (exit $gate_status)"
exit "$gate_status"