1- # Perf-regression benchmarks: instruction-count (Callgrind) gating against a COMMITTED baseline.
1+ # Instruction-count (Callgrind) perf-regression gate against a COMMITTED baseline. No CodSpeed account/token/runner:
2+ # compare_baseline.py parses raw callgrind dumps and diffs each benchmark against benchmarks/baseline.json. Counts
3+ # are near-deterministic with PYTHONHASHSEED pinned (~0.1% noise), so the 5% gate threshold sits far above it.
4+ # Details + rationale: benchmarks/README.md and benchmarks/PLAN.md.
25#
3- # NO CodSpeed account/dashboard/token/runner. pytest-codspeed's hooks call callgrind_dump_stats_at(<uri>) per
4- # benchmark, so a self-hosted `valgrind --tool=callgrind` run writes one dump per benchmark, headed by
5- # `desc: Trigger: Client Request: <uri>` with the count on `totals:` (events: Ir). benchmarks/compare_baseline.py
6- # parses those dumps and diffs each benchmark against benchmarks/baseline.json (the committed instruction-count
7- # baseline). Counts are near-deterministic under Callgrind with PYTHONHASHSEED pinned (~0.1% noise observed;
8- # often bit-identical), so a 5% default gate threshold sits far above noise. Validated on a Linux+valgrind box.
6+ # Triggers: nightly schedule + manual workflow_dispatch (no pull_request/push). A dispatch on a feature branch
7+ # compares that branch's counts vs the baseline.json committed on it, answering "did my branch regress vs main".
98#
10- # TRIGGERS: nightly `schedule` + manual `workflow_dispatch`. No pull_request/push (and no `paths:` -- neither
11- # schedule nor dispatch honors it). A dispatch on a feature branch compares that branch's benchmark counts vs the
12- # baseline.json committed on the branch (i.e. main's baseline), answering "did my branch regress vs main".
9+ # Modes (workflow_dispatch input `regen`):
10+ # regen=false (default) -> COMPARE + report. Report-only for now (never fails); flip to --enforce once trusted.
11+ # regen=true -> write a fresh baseline.json + upload as an artifact to commit deliberately. Bump
12+ # requirements-bench.txt FIRST (separate commit) if the pins should change.
1313#
14- # MODES (workflow_dispatch input `regen`):
15- # regen=false (default) -> COMPARE: run + diff vs baseline.json, print a report. REPORT-ONLY for now (never
16- # fails the job); flip compare_baseline.py to --enforce once trusted.
17- # regen=true -> REGENERATE: run + write a fresh baseline.json (per-bench counts + provenance meta +
18- # Option-B binding fractions/auto-move) and upload it as an artifact to commit
19- # deliberately. Bump benchmarks/requirements-bench.txt in a separate commit FIRST if
20- # the pins should change, then regen so the baseline matches the committed pins.
21- #
22- # The concurrency module is EXCLUDED from the Callgrind sweep: Callgrind serializes threads, so its signal
23- # (wall-clock GIL contention) is meaningless here; it stays a local walltime tool.
24- #
25- # MEMORY MODE (a second Callgrind sweep for O(rows) produce peak-RSS) is DESIGNED but DEFERRED -- see PLAN.md.
26- #
27- # Valgrind is slow (~20-50x); timeout-minutes is a conservative guess -- calibrate after the first CI run.
14+ # The concurrency module is excluded from the sweep (Callgrind serializes threads, so its signal is meaningless).
15+ # Memory mode (a second sweep for produce peak-RSS) is deferred (see PLAN.md).
2816
2917name : Benchmarks
3018
@@ -45,17 +33,14 @@ concurrency:
4533jobs :
4634 benchmarks :
4735 runs-on : ubuntu-latest
48- timeout-minutes : 90 # measured: ~25 min Callgrind sweep at BENCH_SCALE=10 (12-core Linux) + cold build ~10 min; margin for CI
36+ timeout-minutes : 90 # ~25 min sweep at BENCH_SCALE=10 (12-core Linux) + ~10 min cold build ; margin for CI
4937 permissions :
5038 contents : read
5139 env :
52- PYTHONHASHSEED : " 0" # pin hash randomization so dict/struct paths give stable instruction counts (INFRA-6)
53- CODSPEED_ENV : " 1" # activates pytest-codspeed's instrument hooks (the callgrind_dump_stats_at markers)
54- # env-gated row counts (INFRA-4): shrink the O(rows)/per-row-object benchmarks so the Callgrind sweep fits
55- # under timeout-minutes. Local runs leave this unset -> full N. Recorded in baseline.json meta.bench_scale;
56- # a baseline is only comparable to a run at the SAME scale. Calibrated on a 12-core Linux+valgrind box:
57- # BENCH_SCALE=10 -> ~25 min full sweep, and the Option-B move-list matches full-N (fractions shift slightly
58- # but stay the same side of the cutoff). Most benches floor at 20k rows (_scale.FLOOR), still row-dominated.
40+ PYTHONHASHSEED : " 0" # stable instruction counts for dict/struct paths
41+ CODSPEED_ENV : " 1" # activates pytest-codspeed's instrument hooks
42+ # shrink the O(rows) benches so the sweep fits under timeout-minutes. Local runs leave this unset -> full N.
43+ # Recorded in baseline.json meta.bench_scale; a baseline only compares to a run at the SAME scale.
5944 BENCH_SCALE : " 10"
6045 steps :
6146 - uses : actions/checkout@v4
@@ -95,22 +80,19 @@ jobs:
9580 run : |
9681 # step 1: build deps only (needed for --no-build-isolation), no project
9782 uv sync --only-group build --no-install-project -p 3.13
98- # step 2: build+install the project (release) + build group, WITHOUT the heavy default `dev` group
99- # (torch/tensorflow/pyspark). uv.lock is gitignored, so it is deliberately NOT relied on for bench deps.
83+ # step 2: build+install the project (release) + build group, without the heavy default `dev` group
10084 uv sync --no-build-isolation --no-editable --reinstall --no-default-groups --group build -p 3.13
101- # step 3: install the FROZEN, committed bench pins (exact ==). Regenerated deliberately with the baseline
102- # (source list: pyproject [dependency-groups] bench), so the only cross-run delta is the binding.
85+ # step 3: the frozen bench pins (exact ==), so the only cross-run delta is the binding
10386 uv pip install -r benchmarks/requirements-bench.txt
10487
10588 - name : Collect gate node-ids
106- # the gate/informational split (conftest markers) classifies which benchmarks are gate-able ; regen uses it
89+ # the gate/informational marker split ; regen uses it to classify each benchmark
10790 run : uv run --no-sync pytest benchmarks/ -m gate --collect-only -q -o addopts= -p no:cacheprovider \
10891 | grep '::' > gate_list.txt || true
10992
11093 - name : Run benchmarks under Callgrind (per-benchmark instruction counts)
111- # ONE sweep over all gate+informational benchmarks EXCEPT the concurrency module (Callgrind serializes
112- # threads -> its wall-clock signal is meaningless and it is expensive). Each benchmark emits a callgrind
113- # dump keyed by its uri. The pytest-codspeed hooks obj-skip libpython, so counts are clean.
94+ # ONE sweep over gate+informational EXCEPT the concurrency module (thread-serialized, expensive). Each
95+ # benchmark emits a callgrind dump keyed by its uri.
11496 run : |
11597 mkdir -p profiles
11698 CODSPEED_PROFILE_FOLDER="$PWD/profiles" valgrind --tool=callgrind --instr-atstart=no \
@@ -121,8 +103,7 @@ jobs:
121103
122104 - name : Compare against committed baseline (report-only)
123105 if : ${{ !inputs.regen }}
124- # report-only for now: prints the per-benchmark delta table and NEVER fails the job. Add --enforce here
125- # once trusted to fail on a gate regression (informational benches never fail).
106+ # report-only: prints the delta table, never fails the job. Add --enforce once trusted.
126107 run : |
127108 uv run --no-sync python benchmarks/compare_baseline.py compare \
128109 --profiles profiles --baseline benchmarks/baseline.json \
0 commit comments