Skip to content

Commit 65cf118

Browse files
authored
Merge pull request #3441 from dheerajodha/EC-1819
Establish performance baselines and regression detection
2 parents 83b9142 + cb62743 commit 65cf118

8 files changed

Lines changed: 274 additions & 15 deletions

File tree

.claude/skills/benchmark/SKILL.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ go run . -benchnum 5
5555

5656
Multi-component snapshot with configurable parallelism:
5757

58+
```bash
59+
make benchmark_stress
60+
```
61+
62+
Or manually:
63+
5864
```bash
5965
cd benchmark/stress
6066
./prepare_data.sh
@@ -69,7 +75,30 @@ EC_STRESS_COMPONENTS=50 EC_STRESS_WORKERS=20 go run .
6975

7076
Defaults: 10 components, 35 workers.
7177

72-
## Step 5: Profile if needed
78+
## Step 5: Compare against baseline
79+
80+
The stress benchmark has regression detection. After running:
81+
82+
```bash
83+
cd benchmark/stress
84+
./compare.sh benchmark-output.txt
85+
```
86+
87+
This compares current results against `baseline.json` using thresholds from
88+
`thresholds.json` (default: 15% RSS, 20% ns/op). Exits non-zero on regression.
89+
90+
## Step 6: Regenerate baseline
91+
92+
After intentional performance changes, update the stored baseline:
93+
94+
```bash
95+
make generate-baseline
96+
```
97+
98+
This runs the stress benchmark, parses results, and writes `benchmark/stress/baseline.json`
99+
with current metrics, commit SHA, date, and Go version.
100+
101+
## Step 7: Profile if needed
73102

74103
Use the CLI's built-in profiling:
75104

@@ -79,7 +108,7 @@ ec validate image --trace=cpu ... # pprof CPU profile
79108
ec validate image --trace=mem ... # heap profile
80109
```
81110

82-
## Step 6: Report results
111+
## Step 8: Report results
83112

84113
Output is in standard Go benchmark format (ns/op, memory stats). Summarize:
85114
- Benchmark type run (simple/stress)

.github/workflows/benchmark.yaml

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ jobs:
3232
name: Stress Benchmark
3333
runs-on: ubuntu-latest
3434
timeout-minutes: 15
35-
continue-on-error: true
3635
env:
3736
# Tuned for 4 vCPU / 16 GB CI runners to complete within 5 minutes.
3837
# Code defaults are 10 components / 35 workers.
@@ -73,11 +72,23 @@ jobs:
7372

7473
- name: Run stress benchmark
7574
id: bench
75+
continue-on-error: true
7676
run: |
7777
set -o pipefail
7878
cd benchmark/stress
7979
./stress 2>benchmark-stderr.txt | tee benchmark-output.txt
8080
81+
- name: Compare against baseline
82+
id: compare
83+
if: steps.bench.outcome == 'success'
84+
run: |
85+
cd benchmark/stress
86+
if [[ -f baseline.json ]]; then
87+
./compare.sh benchmark-output.txt
88+
else
89+
echo "No baseline found, skipping comparison."
90+
fi
91+
8192
- name: Write job summary
8293
if: always()
8394
run: |
@@ -103,25 +114,58 @@ jobs:
103114
exit 0
104115
fi
105116
106-
ns_op=$(echo "$line" | grep -oP '[\d.]+ ns/op' | awk '{print $1}')
107-
peak_rss=$(echo "$line" | grep -oP '[\d.]+ peak-RSS-bytes' | awk '{print $1}')
108-
alloc=$(echo "$line" | grep -oP '[\d.]+ allocated-bytes/op' | awk '{print $1}')
109-
heap=$(echo "$line" | grep -oP '[\d.]+ heap-bytes-from-system' | awk '{print $1}')
117+
read -r ns_op peak_rss alloc heap < <(BENCH_LINE="$line" python3 -c "
118+
import os, re
119+
line = os.environ['BENCH_LINE']
120+
def val(p):
121+
m = re.search(p, line)
122+
return m.group(1) if m else '0'
123+
print(val(r'([\d.]+)\s+ns/op'), val(r'([\d.]+)\s+peak-RSS-bytes'), val(r'([\d.]+)\s+allocated-bytes/op'), val(r'([\d.]+)\s+heap-bytes-from-system'))
124+
")
110125
111126
secs=$(awk -v val="${ns_op:-0}" 'BEGIN {printf "%.1f", val / 1000000000}')
112127
rss_mb=$(awk -v val="${peak_rss:-0}" 'BEGIN {printf "%.0f", val / 1048576}')
113128
alloc_mb=$(awk -v val="${alloc:-0}" 'BEGIN {printf "%.0f", val / 1048576}')
114129
heap_mb=$(awk -v val="${heap:-0}" 'BEGIN {printf "%.0f", val / 1048576}')
115130
131+
has_baseline=false
132+
if [[ -f benchmark/stress/baseline.json ]]; then
133+
has_baseline=true
134+
bl_rss=$(python3 -c "import json; print(json.load(open('benchmark/stress/baseline.json'))['peak_rss_bytes'])")
135+
bl_ns=$(python3 -c "import json; print(json.load(open('benchmark/stress/baseline.json'))['ns_per_op'])")
136+
bl_rss_mb=$(awk -v val="$bl_rss" 'BEGIN {printf "%.0f", val / 1048576}')
137+
bl_secs=$(awk -v val="$bl_ns" 'BEGIN {printf "%.1f", val / 1000000000}')
138+
rss_change=$(awk -v cur="$peak_rss" -v base="$bl_rss" 'BEGIN {printf "%+.1f", ((cur - base) / base) * 100}')
139+
time_change=$(awk -v cur="$ns_op" -v base="$bl_ns" 'BEGIN {printf "%+.1f", ((cur - base) / base) * 100}')
140+
fi
141+
116142
{
117143
echo "## Stress Benchmark"
118144
echo ""
119-
echo "| Metric | Value | Description |"
120-
echo "|--------|-------|-------------|"
121-
echo "| Components | ${EC_STRESS_COMPONENTS} | Snapshot components validated |"
122-
echo "| Workers | ${EC_STRESS_WORKERS} | Parallel validation workers |"
123-
echo "| Execution time | ${secs}s | Wall-clock time per iteration |"
124-
echo "| Peak RSS | ${rss_mb} MB | Max physical memory used |"
125-
echo "| Allocated memory | ${alloc_mb} MB | Total Go heap allocations |"
126-
echo "| Heap from system | ${heap_mb} MB | Heap memory requested from OS |"
145+
if [[ "$has_baseline" == "true" ]]; then
146+
echo "| Metric | Current | Baseline | Change | Description |"
147+
echo "|--------|---------|----------|--------|-------------|"
148+
echo "| Components | ${EC_STRESS_COMPONENTS} | | | Snapshot components validated |"
149+
echo "| Workers | ${EC_STRESS_WORKERS} | | | Parallel validation workers |"
150+
echo "| Execution time | ${secs}s | ${bl_secs}s | ${time_change}% | Wall-clock time per iteration |"
151+
echo "| Peak RSS | ${rss_mb} MB | ${bl_rss_mb} MB | ${rss_change}% | Max physical memory used |"
152+
echo "| Allocated memory | ${alloc_mb} MB | | | Total Go heap allocations |"
153+
echo "| Heap from system | ${heap_mb} MB | | | Heap memory requested from OS |"
154+
else
155+
echo "| Metric | Value | Description |"
156+
echo "|--------|-------|-------------|"
157+
echo "| Components | ${EC_STRESS_COMPONENTS} | Snapshot components validated |"
158+
echo "| Workers | ${EC_STRESS_WORKERS} | Parallel validation workers |"
159+
echo "| Execution time | ${secs}s | Wall-clock time per iteration |"
160+
echo "| Peak RSS | ${rss_mb} MB | Max physical memory used |"
161+
echo "| Allocated memory | ${alloc_mb} MB | Total Go heap allocations |"
162+
echo "| Heap from system | ${heap_mb} MB | Heap memory requested from OS |"
163+
fi
164+
if [[ "${{ steps.compare.outcome }}" == "failure" ]]; then
165+
echo ""
166+
echo "> **⚠️ Performance regression detected.** Update the baseline with \`make generate-baseline\` if this is expected."
167+
elif [[ "${{ steps.compare.outcome }}" == "skipped" ]]; then
168+
echo ""
169+
echo "> **ℹ️ Baseline comparison skipped** because the benchmark step failed."
170+
fi
127171
} >> "$GITHUB_STEP_SUMMARY"

AGENTS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,26 @@ Acceptance tests require `/etc/hosts` entries:
7777
127.0.0.1 rekor.localhost
7878
```
7979

80+
## Benchmarks
81+
82+
Two benchmarks live under `benchmark/`:
83+
84+
- **simple/** — Single-component validation against the `@redhat` policy collection.
85+
- **stress/** — Multi-component validation with configurable parallelism
86+
(`EC_STRESS_COMPONENTS`, `EC_STRESS_WORKERS`).
87+
88+
```bash
89+
make benchmark # Run simple benchmark
90+
make benchmark_stress # Run stress benchmark (via pattern rule)
91+
make generate-baseline # Run stress benchmark and write baseline.json
92+
```
93+
94+
The stress benchmark has regression detection: `benchmark/stress/baseline.json` stores
95+
reference metrics (peak RSS, ns/op) and `benchmark/stress/thresholds.json` defines
96+
percentage thresholds. CI runs `benchmark/stress/compare.sh` to compare each run
97+
against the baseline and fails the check on regression. Update the baseline with
98+
`make generate-baseline` after intentional performance changes.
99+
80100
## Single-File Verification
81101

82102
```bash

Makefile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,29 @@ benchmark_data: benchmark/simple/data.tar.gz ## Prepare data for benchmark
197197
.PHONY: benchmark
198198
benchmark: benchmark_simple ## Run benchmarks
199199

200+
.PHONY: generate-baseline
201+
generate-baseline: benchmark/stress/data.tar.gz ## Generate stress benchmark baseline
202+
@cd benchmark/stress && \
203+
EC_STRESS_COMPONENTS=$${EC_STRESS_COMPONENTS:-10} EC_STRESS_WORKERS=$${EC_STRESS_WORKERS:-10} \
204+
go run . 2>benchmark-stderr.txt | tee benchmark-output.txt && \
205+
python3 -c "\
206+
import re, json, sys; \
207+
line = [l for l in open('benchmark-output.txt') if l.startswith('BenchmarkStress')]; \
208+
line or sys.exit('No BenchmarkStress results found'); \
209+
line = line[0]; \
210+
ns_m = re.search(r'([\d.]+)\s+ns/op', line); \
211+
rss_m = re.search(r'([\d.]+)\s+peak-RSS-bytes', line); \
212+
ns = ns_m.group(1) if ns_m else ''; \
213+
rss = rss_m.group(1) if rss_m else ''; \
214+
(ns and rss) or sys.exit('Failed to parse benchmark metrics'); \
215+
json.dump({'peak_rss_bytes': int(float(rss)), 'ns_per_op': int(float(ns)), \
216+
'components': int('$${EC_STRESS_COMPONENTS:-10}'), 'workers': int('$${EC_STRESS_WORKERS:-10}'), \
217+
'commit': '$(shell git rev-parse --short HEAD)', 'date': '$(shell date -u +%Y-%m-%d)', \
218+
'go_version': '$(shell go env GOVERSION | sed "s/^go//")' \
219+
}, open('baseline.json','w'), indent=2); print()" && \
220+
rm -f benchmark-output.txt benchmark-stderr.txt && \
221+
echo "Baseline written to benchmark/stress/baseline.json"
222+
200223
.PHONY: tools-ci
201224
tools-ci: ## Ensure all tools build cleanly
202225
@echo "• tkn:" && \

benchmark/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,25 @@ times.
1717
- **stress/** — Multi-component validation with configurable parallelism. Set
1818
`EC_STRESS_COMPONENTS` (default 10) and `EC_STRESS_WORKERS` (default 35) to
1919
control the workload.
20+
21+
## Baseline and regression detection
22+
23+
The stress benchmark stores a performance baseline in
24+
`stress/baseline.json` (peak RSS and ns/op) along with configurable
25+
regression thresholds in `stress/thresholds.json`. The CI workflow
26+
compares each run against the baseline and fails the check when a metric
27+
exceeds its threshold.
28+
29+
To regenerate the baseline after an intentional change:
30+
31+
```
32+
make generate-baseline
33+
```
34+
35+
This runs the stress benchmark locally, parses the results, and writes a
36+
new `baseline.json` with the current commit SHA, date, Go version, and
37+
worker/component counts.
38+
39+
Thresholds are expressed as percentages (e.g., 15 means a 15% increase
40+
triggers a failure). Adjust them in `stress/thresholds.json` as
41+
optimizations land.

benchmark/stress/baseline.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"peak_rss_bytes": 2250485760,
3+
"ns_per_op": 2567888013,
4+
"components": 10,
5+
"workers": 10,
6+
"commit": "fc37eb13",
7+
"date": "2026-08-11",
8+
"go_version": "1.26.3"
9+
}

benchmark/stress/compare.sh

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/bin/bash
2+
# Copyright The Conforma Contributors
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
# SPDX-License-Identifier: Apache-2.0
17+
18+
# Compares current benchmark results against a stored baseline and exits
19+
# non-zero if any metric regresses beyond the configured threshold.
20+
set -o errexit
21+
set -o nounset
22+
set -o pipefail
23+
24+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
25+
BASELINE="${SCRIPT_DIR}/baseline.json"
26+
THRESHOLDS="${SCRIPT_DIR}/thresholds.json"
27+
BENCHMARK_OUTPUT="${1:-${SCRIPT_DIR}/benchmark-output.txt}"
28+
29+
if [[ ! -f "$BASELINE" ]]; then
30+
echo "No baseline found, skipping comparison."
31+
exit 0
32+
fi
33+
34+
if [[ ! -f "$THRESHOLDS" ]]; then
35+
echo "No thresholds file found, skipping comparison."
36+
exit 0
37+
fi
38+
39+
if [[ ! -f "$BENCHMARK_OUTPUT" ]]; then
40+
echo "No benchmark output found at ${BENCHMARK_OUTPUT}" >&2
41+
exit 1
42+
fi
43+
44+
line=$(grep '^BenchmarkStress' "$BENCHMARK_OUTPUT" || true)
45+
if [[ -z "$line" ]]; then
46+
echo "No BenchmarkStress results found in output." >&2
47+
exit 1
48+
fi
49+
50+
read -r current_ns current_rss baseline_ns baseline_rss threshold_rss threshold_time < <(
51+
BENCH_LINE="${line}" BASELINE_PATH="${BASELINE}" THRESHOLDS_PATH="${THRESHOLDS}" python3 -c "
52+
import json, os, re, sys
53+
line = os.environ['BENCH_LINE']
54+
def extract(pattern):
55+
m = re.search(pattern, line)
56+
return m.group(1) if m else ''
57+
ns = extract(r'([\d.]+)\s+ns/op')
58+
rss = extract(r'([\d.]+)\s+peak-RSS-bytes')
59+
if not ns or not rss:
60+
print('Failed to parse benchmark metrics from output.', file=sys.stderr)
61+
sys.exit(1)
62+
b = json.load(open(os.environ['BASELINE_PATH']))
63+
t = json.load(open(os.environ['THRESHOLDS_PATH']))
64+
print(ns, rss, b['ns_per_op'], b['peak_rss_bytes'], t['peak_rss_percent'], t['ns_per_op_percent'])
65+
"
66+
)
67+
68+
if awk -v rss="$baseline_rss" -v ns="$baseline_ns" 'BEGIN {exit !(rss==0 || ns==0)}'; then
69+
echo "Baseline contains zero values, cannot compute regression." >&2
70+
exit 1
71+
fi
72+
73+
rss_change=$(awk -v cur="$current_rss" -v base="$baseline_rss" 'BEGIN {printf "%.1f", ((cur - base) / base) * 100}')
74+
time_change=$(awk -v cur="$current_ns" -v base="$baseline_ns" 'BEGIN {printf "%.1f", ((cur - base) / base) * 100}')
75+
76+
baseline_rss_mb=$(awk -v val="$baseline_rss" 'BEGIN {printf "%.0f", val / 1048576}')
77+
current_rss_mb=$(awk -v val="$current_rss" 'BEGIN {printf "%.0f", val / 1048576}')
78+
baseline_secs=$(awk -v val="$baseline_ns" 'BEGIN {printf "%.1f", val / 1000000000}')
79+
current_secs=$(awk -v val="$current_ns" 'BEGIN {printf "%.1f", val / 1000000000}')
80+
81+
echo ""
82+
echo "=== Benchmark Comparison ==="
83+
echo ""
84+
printf "%-20s %10s %10s %10s %10s\n" "Metric" "Baseline" "Current" "Change" "Threshold"
85+
printf "%-20s %10s %10s %9s%% %9s%%\n" "Peak RSS" "${baseline_rss_mb} MB" "${current_rss_mb} MB" "$rss_change" "$threshold_rss"
86+
printf "%-20s %10s %10s %9s%% %9s%%\n" "Execution time" "${baseline_secs}s" "${current_secs}s" "$time_change" "$threshold_time"
87+
echo ""
88+
89+
failed=0
90+
91+
rss_exceeded=$(awk -v change="$rss_change" -v thresh="$threshold_rss" 'BEGIN {print (change > thresh) ? 1 : 0}')
92+
time_exceeded=$(awk -v change="$time_change" -v thresh="$threshold_time" 'BEGIN {print (change > thresh) ? 1 : 0}')
93+
94+
if [[ "$rss_exceeded" == "1" ]]; then
95+
echo "FAIL: Peak RSS regressed by ${rss_change}% (threshold: ${threshold_rss}%)"
96+
failed=1
97+
fi
98+
99+
if [[ "$time_exceeded" == "1" ]]; then
100+
echo "FAIL: Execution time regressed by ${time_change}% (threshold: ${threshold_time}%)"
101+
failed=1
102+
fi
103+
104+
if [[ "$failed" == "0" ]]; then
105+
echo "PASS: No regressions detected."
106+
fi
107+
108+
exit "$failed"

benchmark/stress/thresholds.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"peak_rss_percent": 15,
3+
"ns_per_op_percent": 20
4+
}

0 commit comments

Comments
 (0)