Skip to content

Commit cd141ac

Browse files
committed
ci: add cross-platform benchmark comparison against the branch baseline
Runs the end-to-end benchmarks at the merge-base and at head on the same runner, on arm64 and x64, and reports per-benchmark deltas. Significance comes from confidence interval overlap rather than a raw percentage: comparing two runs of identical code locally produced up to 8.6 percent drift, and one case cleared a 5 percent threshold with nothing changed.
1 parent dcd9bdc commit cd141ac

2 files changed

Lines changed: 369 additions & 0 deletions

File tree

.github/scripts/compare-bench.cs

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// Compares two BenchmarkDotNet artifact directories and reports the per-benchmark delta.
2+
//
3+
// dotnet run .github/scripts/compare-bench.cs -- <baseline-dir> <head-dir> [options]
4+
//
5+
// --threshold N percent change treated as meaningful once significant (default 10)
6+
// --label TEXT prefix for the report heading and warning annotations
7+
// --fail-on-regression exit non-zero when a benchmark regresses beyond the threshold
8+
//
9+
// Significance is decided by confidence-interval overlap, not by a bare percentage. Shared CI
10+
// runners drift enough that a percentage on means alone invents regressions. If the two intervals
11+
// overlap, the runs are statistically indistinguishable and the row is reported as noise however
12+
// large the difference in means looks. The percentage threshold then applies on top of a result that
13+
// is already significant, filtering out real-but-trivial movement.
14+
15+
using System.Globalization;
16+
using System.Text;
17+
using System.Text.Json;
18+
19+
var positional = new List<string>();
20+
var threshold = 10.0;
21+
var label = "";
22+
var failOnRegression = false;
23+
24+
for (var i = 0; i < args.Length; i++)
25+
{
26+
switch (args[i])
27+
{
28+
case "--threshold":
29+
threshold = double.Parse(args[++i], CultureInfo.InvariantCulture);
30+
break;
31+
case "--label":
32+
label = args[++i];
33+
break;
34+
case "--fail-on-regression":
35+
failOnRegression = true;
36+
break;
37+
default:
38+
positional.Add(args[i]);
39+
break;
40+
}
41+
}
42+
43+
if (positional.Count < 2)
44+
{
45+
Console.Error.WriteLine("usage: compare-bench.cs <baseline-dir> <head-dir> [--threshold N] [--label TEXT] [--fail-on-regression]");
46+
return 2;
47+
}
48+
49+
var (baseline, baselineFiles) = Load(positional[0]);
50+
var (head, headFiles) = Load(positional[1]);
51+
52+
if (baseline.Count == 0 || head.Count == 0)
53+
{
54+
Console.WriteLine($"::error::No benchmark results found (baseline: {baseline.Count} records from " +
55+
$"{baselineFiles} files, head: {head.Count} records from {headFiles} files)");
56+
return 1;
57+
}
58+
59+
var rows = new List<string[]>();
60+
var regressions = new List<(string Name, double Delta)>();
61+
var improvements = new List<(string Name, double Delta)>();
62+
63+
foreach (var name in baseline.Keys.Union(head.Keys).OrderBy(k => k, StringComparer.Ordinal))
64+
{
65+
var shortName = ShortName(name);
66+
var hasBefore = baseline.TryGetValue(name, out var before);
67+
var hasAfter = head.TryGetValue(name, out var after);
68+
69+
if (!hasBefore)
70+
{
71+
rows.Add([shortName, "-", after.Mean.ToString("F1", CultureInfo.InvariantCulture), "new", ""]);
72+
continue;
73+
}
74+
75+
if (!hasAfter)
76+
{
77+
rows.Add([shortName, before.Mean.ToString("F1", CultureInfo.InvariantCulture), "-", "removed", ""]);
78+
continue;
79+
}
80+
81+
var delta = (after.Mean - before.Mean) / before.Mean * 100.0;
82+
// Non-overlapping intervals mean the difference exceeds the measured noise.
83+
var overlap = !(after.Lower > before.Upper || after.Upper < before.Lower);
84+
85+
string verdict;
86+
if (overlap)
87+
{
88+
verdict = "noise";
89+
}
90+
else if (delta > threshold)
91+
{
92+
verdict = "SLOWER";
93+
regressions.Add((shortName, delta));
94+
}
95+
else if (delta < -threshold)
96+
{
97+
verdict = "faster";
98+
improvements.Add((shortName, delta));
99+
}
100+
else
101+
{
102+
verdict = "same";
103+
}
104+
105+
rows.Add([
106+
shortName,
107+
before.Mean.ToString("F1", CultureInfo.InvariantCulture),
108+
after.Mean.ToString("F1", CultureInfo.InvariantCulture),
109+
verdict,
110+
delta.ToString("+0.0;-0.0", CultureInfo.InvariantCulture) + "%",
111+
]);
112+
}
113+
114+
var report = new StringBuilder();
115+
report.AppendLine($"## Benchmark{(label.Length > 0 ? ": " + label : "")}");
116+
report.AppendLine();
117+
report.AppendLine("Mean nanoseconds. `noise` means the confidence intervals overlap, so the two runs are "
118+
+ "statistically indistinguishable regardless of the percentage shown.");
119+
report.AppendLine();
120+
report.AppendLine("| Benchmark | Base | Head | Verdict | Delta |");
121+
report.AppendLine("| --- | ---: | ---: | --- | ---: |");
122+
foreach (var row in rows)
123+
{
124+
report.AppendLine("| " + string.Join(" | ", row) + " |");
125+
}
126+
127+
report.AppendLine();
128+
if (regressions.Count > 0)
129+
{
130+
report.AppendLine($"**{regressions.Count} significant regression(s) over {threshold}%:** "
131+
+ string.Join(", ", regressions.Select(r => $"{r.Name} ({r.Delta:+0.0;-0.0}%)")));
132+
}
133+
134+
if (improvements.Count > 0)
135+
{
136+
report.AppendLine($"**{improvements.Count} significant improvement(s):** "
137+
+ string.Join(", ", improvements.Select(r => $"{r.Name} ({r.Delta:+0.0;-0.0}%)")));
138+
}
139+
140+
if (regressions.Count == 0 && improvements.Count == 0)
141+
{
142+
report.AppendLine("No statistically significant change.");
143+
}
144+
145+
Console.WriteLine(report.ToString());
146+
147+
var summary = Environment.GetEnvironmentVariable("GITHUB_STEP_SUMMARY");
148+
if (!string.IsNullOrEmpty(summary))
149+
{
150+
File.AppendAllText(summary, report.ToString() + Environment.NewLine);
151+
}
152+
153+
foreach (var (name, delta) in regressions)
154+
{
155+
Console.WriteLine($"::warning::{label} {name} is {delta:+0.0;-0.0}% slower than baseline");
156+
}
157+
158+
if (regressions.Count > 0 && failOnRegression)
159+
{
160+
Console.WriteLine($"::error::{regressions.Count} benchmark(s) regressed beyond {threshold}%");
161+
return 1;
162+
}
163+
164+
return 0;
165+
166+
static (Dictionary<string, Stat> Results, int FileCount) Load(string directory)
167+
{
168+
var results = new Dictionary<string, Stat>(StringComparer.Ordinal);
169+
var files = Directory.GetFiles(directory, "*-report-full-compressed.json", SearchOption.AllDirectories);
170+
if (files.Length == 0)
171+
{
172+
// Older BenchmarkDotNet versions emit the uncompressed name instead.
173+
files = Directory.GetFiles(directory, "*-report-full.json", SearchOption.AllDirectories);
174+
}
175+
176+
foreach (var path in files)
177+
{
178+
// ReadAllText strips the UTF-8 BOM that BenchmarkDotNet writes; JsonDocument would choke on it.
179+
using var document = JsonDocument.Parse(File.ReadAllText(path));
180+
if (!document.RootElement.TryGetProperty("Benchmarks", out var benchmarks))
181+
{
182+
continue;
183+
}
184+
185+
foreach (var bench in benchmarks.EnumerateArray())
186+
{
187+
if (!bench.TryGetProperty("Statistics", out var stats) || stats.ValueKind != JsonValueKind.Object)
188+
{
189+
continue;
190+
}
191+
192+
var mean = stats.GetProperty("Mean").GetDouble();
193+
var lower = mean;
194+
var upper = mean;
195+
if (stats.TryGetProperty("ConfidenceInterval", out var ci) && ci.ValueKind == JsonValueKind.Object)
196+
{
197+
lower = ci.GetProperty("Lower").GetDouble();
198+
upper = ci.GetProperty("Upper").GetDouble();
199+
}
200+
201+
// Key on DisplayInfo, not FullName. FullName omits the job, so a class carrying two
202+
// [SimpleJob] attributes (several here pair Net90 with Net10_0) or a --job argument on the
203+
// command line produces multiple records sharing one FullName. Keying on FullName silently
204+
// keeps whichever was parsed last, and can pair a ShortRun baseline against a default-job
205+
// head -- a plausible-looking number that means nothing.
206+
var key = bench.GetProperty("DisplayInfo").GetString()!;
207+
results[key] = new Stat(mean, lower, upper);
208+
}
209+
}
210+
211+
return (results, files.Length);
212+
}
213+
214+
// "Class.Method: .NET 10.0(Runtime=.NET 10.0) [Size=32]" -> "Class.Method: .NET 10.0 [Size=32]"
215+
static string ShortName(string display)
216+
{
217+
var trimmed = System.Text.RegularExpressions.Regex.Replace(display, @"\([^)]*\)", "");
218+
return trimmed.Replace("Base58Encoding.Benchmarks.", "").Trim();
219+
}
220+
221+
readonly record struct Stat(double Mean, double Lower, double Upper);

.github/workflows/benchmark.yml

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
name: benchmark
2+
3+
# Runs the end-to-end benchmarks twice on the SAME runner -- once at a baseline commit, once at the
4+
# current head -- and reports the per-benchmark delta. Both runs share one physical machine and one
5+
# job, which is the only meaningful noise control available on shared CI hardware: comparing numbers
6+
# across two jobs, or against numbers recorded on another day, mostly measures the runners.
7+
#
8+
# Intended to be manual: a full matrix run is two revisions x several minutes of compute on two
9+
# runners, far too expensive to attach to every push. The push trigger below is a temporary
10+
# bootstrap and is scoped to this workflow's own files.
11+
#
12+
# To measure a runner's own noise floor, set baseline_ref to the same commit as the branch: the
13+
# workflow then compares a revision against itself and everything it reports is drift, not signal.
14+
15+
on:
16+
# TEMPORARY: workflow_dispatch only becomes dispatchable once the file is on the default branch, so
17+
# this trigger exists purely to validate the workflow before merge. Remove it afterwards -- a full
18+
# matrix run per push is far too expensive to keep.
19+
push:
20+
branches: [ 'perf/widening-multiply-32' ]
21+
paths:
22+
- '.github/workflows/benchmark.yml'
23+
- '.github/scripts/compare-bench.cs'
24+
workflow_dispatch:
25+
inputs:
26+
baseline_ref:
27+
description: 'Baseline git ref. Default: merge-base with origin/master (i.e. where this branch started).'
28+
required: false
29+
type: string
30+
filter:
31+
description: 'BenchmarkDotNet --filter glob.'
32+
required: false
33+
default: '*EndToEnd*Benchmark*'
34+
type: string
35+
threshold:
36+
description: 'Percent change treated as meaningful, on top of non-overlapping confidence intervals.'
37+
required: false
38+
default: '10'
39+
type: string
40+
fail_on_regression:
41+
description: 'Fail the job when a benchmark regresses beyond the threshold.'
42+
required: false
43+
default: false
44+
type: boolean
45+
46+
jobs:
47+
bench:
48+
strategy:
49+
fail-fast: false
50+
matrix:
51+
include:
52+
- runner: ubuntu-24.04-arm
53+
label: arm64
54+
- runner: ubuntu-24.04
55+
label: x64
56+
runs-on: ${{ matrix.runner }}
57+
timeout-minutes: 60
58+
permissions:
59+
contents: read
60+
61+
steps:
62+
- uses: actions/checkout@v7
63+
with:
64+
# Full history: the default baseline is the merge-base with master, which a shallow clone
65+
# cannot compute.
66+
fetch-depth: 0
67+
68+
- name: Setup .NET
69+
uses: actions/setup-dotnet@v5
70+
with:
71+
dotnet-version: 10.0.x
72+
73+
# Record the actual CPU. GitHub's x64 pool mixes Intel Xeon and AMD EPYC, and they differ enough
74+
# (AVX-512 presence, cache, clocks) that a delta is only interpretable next to the model name.
75+
- name: Host
76+
run: |
77+
uname -m
78+
if command -v lscpu >/dev/null 2>&1; then
79+
lscpu | sed -n '1,20p'
80+
else
81+
sysctl -n machdep.cpu.brand_string hw.ncpu
82+
fi
83+
84+
- name: Resolve baseline
85+
id: baseline
86+
run: |
87+
set -euo pipefail
88+
if [ -n "${{ inputs.baseline_ref }}" ]; then
89+
BASE=$(git rev-parse "${{ inputs.baseline_ref }}")
90+
else
91+
git fetch --no-tags origin master
92+
BASE=$(git merge-base origin/master HEAD)
93+
fi
94+
HEAD_SHA=$(git rev-parse HEAD)
95+
echo "base=$BASE" >> "$GITHUB_OUTPUT"
96+
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
97+
echo "Baseline : $BASE ($(git log -1 --format=%s "$BASE"))"
98+
echo "Head : $HEAD_SHA ($(git log -1 --format=%s "$HEAD_SHA"))"
99+
if [ "$BASE" = "$HEAD_SHA" ]; then
100+
echo "::notice::Baseline and head are the same commit - this run measures the runner's noise floor."
101+
fi
102+
103+
# --launchCount 3 starts three separate processes per benchmark so the reported confidence
104+
# interval includes process-to-process variance, not just within-process variance. Without it the
105+
# intervals are tight enough (~1.7% margin) that ordinary drift clears them and reads as a real
106+
# regression; that was reproduced locally on identical code at +5.4%.
107+
#
108+
# No --job flag: the benchmark classes already carry [SimpleJob(RuntimeMoniker.Net10_0)], and a
109+
# --job argument ADDS a second job rather than replacing it, producing two sets of results per
110+
# benchmark under one FullName.
111+
- name: Benchmark baseline
112+
run: |
113+
set -euo pipefail
114+
git checkout --quiet --detach ${{ steps.baseline.outputs.base }}
115+
dotnet restore src/Base58Encoding.slnx
116+
dotnet run --project src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj \
117+
--configuration Release -- \
118+
--filter '${{ inputs.filter || '*EndToEnd*Benchmark*' }}' --launchCount 3 \
119+
--exporters json --artifacts "$RUNNER_TEMP/bench-base"
120+
121+
- name: Benchmark head
122+
run: |
123+
set -euo pipefail
124+
git checkout --quiet --detach ${{ steps.baseline.outputs.head }}
125+
dotnet restore src/Base58Encoding.slnx
126+
dotnet run --project src/Base58Encoding.Benchmarks/Base58Encoding.Benchmarks.csproj \
127+
--configuration Release -- \
128+
--filter '${{ inputs.filter || '*EndToEnd*Benchmark*' }}' --launchCount 3 \
129+
--exporters json --artifacts "$RUNNER_TEMP/bench-head"
130+
131+
# A .NET 10 file-based app rather than a shell or python script: the SDK is already set up on the
132+
# runner, so this needs no extra toolchain, and it stays in the language of the repo.
133+
- name: Compare
134+
run: |
135+
dotnet run .github/scripts/compare-bench.cs -- \
136+
"$RUNNER_TEMP/bench-base" "$RUNNER_TEMP/bench-head" \
137+
--threshold '${{ inputs.threshold || '10' }}' \
138+
--label '${{ matrix.label }} (${{ matrix.runner }})' \
139+
${{ inputs.fail_on_regression && '--fail-on-regression' || '' }}
140+
141+
- name: Upload raw results
142+
if: always()
143+
uses: actions/upload-artifact@v7
144+
with:
145+
name: bench-${{ matrix.label }}
146+
path: |
147+
${{ runner.temp }}/bench-base/**/*.json
148+
${{ runner.temp }}/bench-head/**/*.json

0 commit comments

Comments
 (0)