|
| 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); |
0 commit comments