Source
scripts/bench_gate.sh
1
#!/usr/bin/env bash2
# bench_gate.sh — cheatah::linalg::Fixed must never become slower than GLM.3
#4
# tests/benchmarks/fixed_glm_bench.cpp measures the COMPLETE overlap of the two APIs as5
# BM_<op>_fixed / BM_<op>_glm pairs. This gate runs them and fails if any pair regresses. Without it,6
# "Fixed is as fast as GLM" is a claim that was true once, on one commit, on one machine.7
#8
# Three things keep it from being a flaky gate:9
#10
# 1. A TOLERANCE. Most of these operations take one or two cycles, where measurement noise is a11
# larger effect than any code change; run-to-run sign flips of +/-5% are normal. A pair fails12
# only above THRESHOLD (default 1.15x).13
# 2. An ABSOLUTE FLOOR. A ratio is meaningless on a sub-nanosecond operation. `dot` on a 3-vector14
# of doubles compiles to instruction-identical code in both libraries — the same movsd/mulsd/15
# addsd sequence, verified by reading the assembly — and still measures ~0.09 ns apart, because16
# at that scale the harness's own DoNotOptimize scaffolding dominates. So a pair must ALSO be17
# slower by more than MIN_GAP_NS (default 0.25 ns, about one cycle) to fail. Anything under18
# that is reported, never fatal. The floor is one cycle, not half, because in the QA gate the19
# benchmarks run LAST — after the ASan/TSan/Valgrind stages have pinned the CPU for ~15 min —20
# so boost clocks are depressed and the smallest ops (a 16-double mat4 add is ~1.4 ns) drift a21
# few tenths of a nanosecond hotter than a cold run; that thermal tail must not fail the build.22
# 3. CONFIRMATION. Anything that trips both is re-measured, alone, with more repetitions. A single23
# noisy sample never fails the build; a real regression survives the second look.24
#25
# scripts/bench_gate.sh # gate (build if needed, then check)26
# THRESHOLD=1.10 scripts/bench_gate.sh27
# scripts/bench_gate.sh report # print every pair, fail nothing28
set -uo pipefail29
cd "$(git rev-parse --show-toplevel)"31
MODE="${1:-gate}"32
THRESHOLD="${THRESHOLD:-1.15}"33
MIN_GAP_NS="${MIN_GAP_NS:-0.25}"34
BIN=build/release/bin/cheatah_benchmarks36
bold() { printf '\n\033[1m[bench-gate] %s\033[0m\n' "$*"; }37
skip() { printf '\033[33m[bench-gate] SKIP: %s\033[0m\n' "$*"; exit 0; }38
fail() { printf '\033[31m[bench-gate] FAILED: %s\033[0m\n' "$*"; exit 1; }40
command -v cmake >/dev/null 2>&1 || skip "no cmake"41
if [ ! -x "$BIN" ]; then42
bold "building the benchmarks (release)…"43
cmake --preset release -DCHEATAH_BUILD_BENCHMARKS=ON >/tmp/cheatah_bench_cfg.log 2>&1 \44
|| skip "benchmark configure failed (GLM missing?) — see /tmp/cheatah_bench_cfg.log"45
cmake --build --preset release --target cheatah_benchmarks -j"$(nproc)" >/tmp/cheatah_bench_build.log 2>&1 \46
|| skip "benchmark build failed — see /tmp/cheatah_bench_build.log"47
fi48
[ -x "$BIN" ] || skip "no benchmark binary"50
run_pairs() { # run_pairs <filter> <reps> <min_time> -> csv on stdout51
# --benchmark_enable_random_interleaving shuffles the flat list of repetition slots across52
# every filtered case (benchmark.cc RunBenchmarks), so a pair's _fixed and _glm repetitions53
# are scattered through the run instead of being measured as two consecutive blocks. Without54
# it, any clock or thermal drift over the block lands entirely on one side and shows up as55
# a ratio change; with it, the same drift is zero-mean noise that the median absorbs. The56
# iteration count is calibrated on the first repetition and reused for the rest57
# (benchmark_runner.cc), so scattering the reps does not change what each one measures.58
"$BIN" --benchmark_filter="$1" --benchmark_repetitions="$2" --benchmark_min_time="$3" \59
--benchmark_enable_random_interleaving=true \60
--benchmark_report_aggregates_only=true --benchmark_format=csv 2>/dev/null61
}63
# ---- pass 1: the SCREEN, pair-sharded across cores --------------------------------------------64
# Pass 1 only nominates suspects; pass 2 below re-measures them serially, alone, with more65
# repetitions, and is the ONLY place a failure is declared. That authority split is what makes66
# sharding the screen safe: concurrency noise can at worst nominate extra suspects, each of which67
# pass 2 then measures under the same quiet conditions as always. A pair's _fixed and _glm case68
# are kept in the SAME shard (the comparison is intra-pair, so shared-core noise hits both sides69
# alike); glm-only orphans become one-name units, measured exactly as before. BENCH_GATE_JOBS=170
# reproduces the old single-process pass 1 verbatim. Default 8 (not nproc): a lightly-loaded box71
# keeps the suspect rate near zero, and pass 1 is already ~6x faster at 8.72
BENCH_GATE_JOBS="${BENCH_GATE_JOBS:-8}"73
esc() { printf '%s' "$1" | sed 's/[][\.|$(){}?+*^\\]/\\&/g'; }75
bold "measuring the fixed/glm pairs (pass 1 across ${BENCH_GATE_JOBS} shards)…"76
# Names in this subset never contain whitespace (PAIR-macro generated, BM_<op>_<type>_<side>);77
# if a future one did, the read below would mis-split and the completeness check fails loudly.78
PAIR_NAMES=()79
while IFS= read -r _n; do PAIR_NAMES+=("$_n"); done < <("$BIN" --benchmark_list_tests | grep -E '_(fixed|glm)$')80
[ "${#PAIR_NAMES[@]}" -gt 0 ] || fail "no fixed/glm benchmark cases listed"82
# Group names into pair units: key = name minus its _fixed/_glm suffix. A stable sort by key83
# makes a pair's two sides ADJACENT (fixed before glm, registration order preserved within a84
# key), so one walk builds each unit's alternation. Plain indexed arrays only — macOS stock85
# bash is 3.2 (no associative arrays) and the gate runs there too.86
UNIT_FILTERS=(); _prev_key=""87
while read -r _k _n; do88
_e=$(esc "$_n")89
if [ "$_k" = "$_prev_key" ]; then90
_last=$(( ${#UNIT_FILTERS[@]} - 1 ))91
UNIT_FILTERS[_last]="${UNIT_FILTERS[_last]}|$_e"92
else93
UNIT_FILTERS+=("$_e"); _prev_key="$_k"94
fi95
done < <(for _n in "${PAIR_NAMES[@]}"; do96
_k="${_n%_fixed}"; _k="${_k%_glm}"; printf '%s %s\n' "$_k" "$_n"97
done | sort -s -k1,1)98
shards=$(( BENCH_GATE_JOBS < ${#UNIT_FILTERS[@]} ? BENCH_GATE_JOBS : ${#UNIT_FILTERS[@]} ))99
SHARD_FILTER=()100
for ((i = 0; i < shards; i++)); do SHARD_FILTER[i]=""; done101
for ((i = 0; i < ${#UNIT_FILTERS[@]}; i++)); do102
s=$((i % shards))103
SHARD_FILTER[s]="${SHARD_FILTER[s]}${SHARD_FILTER[s]:+|}${UNIT_FILTERS[i]}"104
done106
pass1_pids=()107
for ((i = 0; i < shards; i++)); do108
: > "/tmp/cheatah_bench_pass1_shard$i.csv" # truncate: a lost shard must yield 0 rows109
# 7 reps, not 5: interleaving raises the per-repetition variance of the sub-nanosecond110
# cases (each rep now starts cold rather than warm behind its own predecessor), and pass 1111
# is only a screen. Two extra reps keep the suspect rate near zero so pass 2 stays cheap.112
run_pairs "^(${SHARD_FILTER[i]})"'$' 7 0.2s > "/tmp/cheatah_bench_pass1_shard$i.csv" &113
pass1_pids+=($!)114
done115
for p in "${pass1_pids[@]}"; do wait "$p" || fail "benchmark run"; done116
: > /tmp/cheatah_bench_pass1.csv117
for ((i = 0; i < shards; i++)); do cat "/tmp/cheatah_bench_pass1_shard$i.csv" >> /tmp/cheatah_bench_pass1.csv; done118
# Completeness: one _median aggregate row per case; the union must equal the listed set.119
measured=$(grep -cE '^"?BM_[^,]*_median"?,' /tmp/cheatah_bench_pass1.csv || true)120
[ "${measured:-0}" -eq "${#PAIR_NAMES[@]}" ] || \121
fail "pass 1 incomplete: measured ${measured:-0}/${#PAIR_NAMES[@]} fixed/glm cases (a shard was lost?)"123
SUSPECTS="$(python3 - "$THRESHOLD" "$MIN_GAP_NS" <<'PY'124
import csv, sys125
threshold, min_gap = float(sys.argv[1]), float(sys.argv[2])126
t = {}127
for row in csv.reader(open('/tmp/cheatah_bench_pass1.csv')):128
if not row or not row[0].startswith('BM_') or not row[0].endswith('_median'):129
continue130
name, ns = row[0][:-len('_median')], float(row[2])131
if name.endswith('_fixed'):132
t.setdefault(name[3:-6], {})['f'] = ns133
elif name.endswith('_glm'):134
t.setdefault(name[3:-4], {})['g'] = ns135
pairs = {k: v for k, v in t.items() if 'f' in v and 'g' in v}136
if not pairs:137
sys.stderr.write("no fixed/glm pairs found — did the benchmark names change?\n")138
sys.exit(2)139
print(' '.join(k for k, v in pairs.items()140
if v['g'] > 0 and v['f'] / v['g'] > threshold and (v['f'] - v['g']) > min_gap))141
PY142
)" || fail "could not parse the benchmark output"144
if [ -z "$SUSPECTS" ]; then145
printf '\n\033[32m[bench-gate] OK — Fixed is within %sx (or %s ns) of GLM on every operation.\033[0m\n' "$THRESHOLD" "$MIN_GAP_NS"146
exit 0147
fi149
bold "re-measuring under suspicion (noise, or a real regression?): $SUSPECTS"150
FILTER="BM_($(echo "$SUSPECTS" | tr ' ' '|'))_(fixed|glm)$"151
run_pairs "$FILTER" 15 0.5s > /tmp/cheatah_bench_pass2.csv || fail "confirmation run"153
python3 - "$THRESHOLD" "$MIN_GAP_NS" "$MODE" <<'PY' || fail "Fixed regressed against GLM (see above)"154
import csv, sys155
threshold, min_gap, mode = float(sys.argv[1]), float(sys.argv[2]), sys.argv[3]156
t = {}157
for row in csv.reader(open('/tmp/cheatah_bench_pass2.csv')):158
if not row or not row[0].startswith('BM_') or not row[0].endswith('_median'):159
continue160
name, ns = row[0][:-len('_median')], float(row[2])161
if name.endswith('_fixed'):162
t.setdefault(name[3:-6], {})['f'] = ns163
elif name.endswith('_glm'):164
t.setdefault(name[3:-4], {})['g'] = ns166
bad = []167
for k, v in sorted(t.items()):168
if 'f' not in v or 'g' not in v or v['g'] <= 0:169
continue170
ratio, gap = v['f'] / v['g'], v['f'] - v['g']171
regressed = ratio > threshold and gap > min_gap172
verdict = 'REGRESSED' if regressed else ('below-floor' if ratio > threshold else 'noise')173
print(f" {verdict:12s} {k:22s} fixed {v['f']:8.3f} ns glm {v['g']:8.3f} ns {ratio:.2f}x (+{gap:.3f} ns)")174
if regressed:175
bad.append((k, ratio, gap))177
if bad and mode != 'report':178
print()179
for k, ratio, gap in bad:180
print(f" {k} is {ratio:.2f}x GLM (+{gap:.3f} ns) and stayed that way under 15 repetitions.")181
sys.exit(1)182
PY184
printf '\n\033[32m[bench-gate] OK — every flagged pair was noise; Fixed holds against GLM.\033[0m\n'