Skip to content

feat: collect benchmark data into 'benchmarks' branch#762

Merged
vbreuss merged 3 commits into
mainfrom
feat/collect-benchmark-data
May 3, 2026
Merged

feat: collect benchmark data into 'benchmarks' branch#762
vbreuss merged 3 commits into
mainfrom
feat/collect-benchmark-data

Conversation

@vbreuss
Copy link
Copy Markdown
Member

@vbreuss vbreuss commented May 3, 2026

Adds two Nuke targets and a docs-site-compatible aggregator so the matrix Benchmarks-* artifacts produced by the build workflow can be turned into the data.js / limited-data.js files consumed by the documentation site (mirroring the aweXpect setup).

  • PublishBenchmarkReport: downloads all Benchmarks-* artifacts of the current run, parses *-report-full-compressed.json files, and commits updated data.js / limited-data.js (last 50 commits) to the 'benchmarks' branch via the GitHub Contents API. The branch is auto-created from main on first run.
  • BenchmarkSeedHistory: one-shot tool that walks past successful build.yml runs on main, downloads each run's Benchmarks-* artifacts, fetches commit metadata from the workflow run head_commit, and seeds the benchmark data files. Idempotent - already-recorded SHAs are skipped.
  • PageBenchmarkReportGenerator: chart key incorporates BenchmarkDotNet Parameters (e.g. 'Method (N=1)'), so each [Params] combination becomes its own chart. Includes Mockolate, Moq, NSubstitute, FakeItEasy, TUnitMocks, Imposter as datasets.
  • build.yml: new publish-benchmark-report job that runs after the matrix benchmarks job on push to main only, with contents: write.

Adds two Nuke targets and a docs-site-compatible aggregator so the
matrix Benchmarks-* artifacts produced by the build workflow can be
turned into the data.js / limited-data.js files consumed by the
documentation site (mirroring the aweXpect setup).

- PublishBenchmarkReport: downloads all Benchmarks-* artifacts of the
  current run, parses *-report-full-compressed.json files, and commits
  updated data.js / limited-data.js (last 50 commits) to the
  'benchmarks' branch via the GitHub Contents API. The branch is
  auto-created from main on first run.
- BenchmarkSeedHistory: one-shot tool that walks past successful
  build.yml runs on main, downloads each run's Benchmarks-* artifacts,
  fetches commit metadata from the workflow run head_commit, and seeds
  the benchmark data files. Idempotent - already-recorded SHAs are
  skipped.
- PageBenchmarkReportGenerator: chart key incorporates BenchmarkDotNet
  Parameters (e.g. 'Method (N=1)'), so each [Params] combination
  becomes its own chart. Includes Mockolate, Moq, NSubstitute,
  FakeItEasy, TUnitMocks, Imposter as datasets.
- build.yml: new publish-benchmark-report job that runs after the
  matrix benchmarks job on push to main only, with contents: write.
@vbreuss vbreuss self-assigned this May 3, 2026
Copilot AI review requested due to automatic review settings May 3, 2026 12:27
@vbreuss vbreuss added the documentation Improvements or additions to documentation label May 3, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds automation to aggregate BenchmarkDotNet JSON artifacts from CI into docs-site-consumable data.js / limited-data.js, publishing them to a dedicated benchmarks branch (and providing a one-shot history seeding target).

Changes:

  • Introduces PageBenchmarkReportGenerator to aggregate BenchmarkDotNet JSON reports into window.BENCHMARK_DATA JS payloads (including parameterized chart keys).
  • Adds Nuke targets PublishBenchmarkReport and BenchmarkSeedHistory plus GitHub Contents/Actions API helpers in BuildExtensions.
  • Extends build.yml with a post-benchmarks job to publish the aggregated benchmark data to the benchmarks branch.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
Pipeline/PageBenchmarkReportGenerator.cs New aggregator that converts BenchmarkDotNet JSON into docs-site data.js / limited-data.js.
Pipeline/BuildExtensions.cs Adds GitHub API helpers for branch creation, file read/write, and workflow run listing.
Pipeline/Build.Benchmarks.cs Adds targets to publish benchmark data for current run and seed from historical runs.
.nuke/build.schema.json Exposes new targets/parameter to Nuke tooling schema.
.github/workflows/build.yml Adds a publish job that runs on main after benchmark matrix completes.

Comment thread Pipeline/BuildExtensions.cs
Comment thread Pipeline/BuildExtensions.cs
Comment thread Pipeline/Build.Benchmarks.cs
Comment thread Pipeline/Build.Benchmarks.cs Outdated
Comment thread .github/workflows/build.yml
Comment thread Pipeline/PageBenchmarkReportGenerator.cs
Comment thread Pipeline/PageBenchmarkReportGenerator.cs
Comment thread Pipeline/BuildExtensions.cs Outdated
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 3, 2026

Test Results

    24 files  ±0      24 suites  ±0   9m 0s ⏱️ -30s
 4 085 tests ±0   4 083 ✅ ±0  2 💤 ±0  0 ❌ ±0 
26 381 runs  ±0  26 377 ✅ ±0  4 💤 ±0  0 ❌ ±0 

Results for commit 528d109. ± Comparison against base commit b6038ca.

♻️ This comment has been updated with latest results.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 3, 2026

🚀 Benchmark Results

Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
Intel Xeon Platinum 8370C CPU 2.80GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v4

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

CreateMock Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 59.86 ns 0.823 ns 0.687 ns 0.86 440 B 1.00
Mockolate 69.78 ns 1.875 ns 1.662 ns 1.00 440 B 1.00
Imposter 310.23 ns 7.710 ns 7.212 ns 4.45 2248 B 5.11
TUnitMocks 44.48 ns 1.014 ns 0.899 ns 0.64 224 B 0.51
Moq 1,324.51 ns 8.845 ns 7.841 ns 18.99 2096 B 4.76
NSubstitute 1,952.76 ns 21.167 ns 19.800 ns 28.00 5048 B 11.47
FakeItEasy 1,726.59 ns 26.908 ns 23.853 ns 24.76 2772 B 6.30
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.89GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Event Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 338.4 ns 4.46 ns 4.17 ns 1.09 1.78 KB 1.00
Mockolate 310.6 ns 5.93 ns 5.26 ns 1.00 1.78 KB 1.00
Imposter 1,390.2 ns 20.28 ns 18.97 ns 4.48 8.8 KB 4.94
TUnitMocks 197.5 ns 2.84 ns 2.66 ns 0.64 1.37 KB 0.77
Moq 16,557.2 ns 73.39 ns 61.28 ns 53.32 12.51 KB 7.02
NSubstitute 5,755.1 ns 24.81 ns 20.71 ns 18.53 9.05 KB 5.08
FakeItEasy 217,386.8 ns 2,281.70 ns 2,134.31 ns 700.01 15.26 KB 8.57
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.73GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Indexer N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 1,000.7 ns 22.84 ns 21.36 ns 1.01 3.81 KB 1.00
Mockolate 1 992.9 ns 30.81 ns 28.82 ns 1.00 3.81 KB 1.00
Imposter 1 847.7 ns 12.18 ns 11.39 ns 0.85 5.16 KB 1.35
Moq 1 215,999.9 ns 916.67 ns 857.46 ns 217.72 20.37 KB 5.34
NSubstitute 1 9,401.6 ns 79.27 ns 74.15 ns 9.48 12.84 KB 3.37
FakeItEasy 1 11,870.7 ns 109.89 ns 102.79 ns 11.97 13.63 KB 3.57
baseline* 10 2,578.5 ns 9.75 ns 8.15 ns 1.03 4.87 KB 1.00
Mockolate 10 2,502.7 ns 44.56 ns 41.68 ns 1.00 4.87 KB 1.00
Imposter 10 2,150.4 ns 26.82 ns 25.09 ns 0.86 7.97 KB 1.64
Moq 10 227,900.9 ns 816.86 ns 764.09 ns 91.08 29.89 KB 6.14
NSubstitute 10 22,399.6 ns 228.56 ns 202.62 ns 8.95 25.63 KB 5.27
FakeItEasy 10 24,880.2 ns 186.60 ns 174.55 ns 9.94 32.97 KB 6.77
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.87GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Method N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 355.1 ns 1.57 ns 1.31 ns 0.93 2.04 KB 1.00
Mockolate 1 383.1 ns 6.29 ns 5.88 ns 1.00 2.04 KB 1.00
Imposter 1 537.0 ns 4.94 ns 4.62 ns 1.40 4.04 KB 1.98
TUnitMocks 1 699.1 ns 8.69 ns 8.13 ns 1.83 2.9 KB 1.42
Moq 1 134,450.6 ns 672.47 ns 596.13 ns 351.05 14.73 KB 7.23
NSubstitute 1 5,372.4 ns 54.43 ns 48.25 ns 14.03 9.12 KB 4.47
FakeItEasy 1 5,337.0 ns 29.99 ns 25.05 ns 13.93 8.05 KB 3.95
baseline* 10 646.8 ns 3.91 ns 3.66 ns 1.00 2.25 KB 1.00
Mockolate 10 649.3 ns 4.52 ns 4.23 ns 1.00 2.25 KB 1.00
Imposter 10 1,113.5 ns 10.43 ns 9.76 ns 1.71 5.52 KB 2.45
TUnitMocks 10 1,801.0 ns 31.47 ns 29.44 ns 2.77 4.49 KB 2.00
Moq 10 139,813.8 ns 1,254.41 ns 1,112.00 ns 215.34 18.64 KB 8.28
NSubstitute 10 8,134.5 ns 75.55 ns 63.09 ns 12.53 12.07 KB 5.37
FakeItEasy 10 8,719.2 ns 72.64 ns 67.95 ns 13.43 15.42 KB 6.85
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 9V74 2.60GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Property N Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 1 512.8 ns 3.37 ns 2.98 ns 0.95 2.46 KB 1.00
Mockolate 1 539.6 ns 9.96 ns 8.83 ns 1.00 2.46 KB 1.00
Imposter 1 429.5 ns 6.19 ns 5.49 ns 0.80 3.13 KB 1.27
TUnitMocks 1 709.1 ns 10.94 ns 9.14 ns 1.31 2.51 KB 1.02
Moq 1 10,346.1 ns 129.07 ns 114.42 ns 19.18 10.39 KB 4.22
NSubstitute 1 6,821.7 ns 19.50 ns 15.22 ns 12.65 11.45 KB 4.65
FakeItEasy 1 7,338.2 ns 74.08 ns 65.67 ns 13.60 11.24 KB 4.57
baseline* 10 1,114.5 ns 16.92 ns 15.83 ns 1.05 2.95 KB 1.00
Mockolate 10 1,061.1 ns 18.29 ns 17.11 ns 1.00 2.95 KB 1.00
Imposter 10 1,069.0 ns 8.57 ns 7.60 ns 1.01 4.67 KB 1.58
TUnitMocks 10 2,154.8 ns 27.28 ns 25.52 ns 2.03 4.66 KB 1.58
Moq 10 16,698.6 ns 179.52 ns 167.92 ns 15.74 18.28 KB 6.19
NSubstitute 10 15,251.3 ns 173.05 ns 153.40 ns 14.38 21.08 KB 7.14
FakeItEasy 10 17,851.5 ns 359.19 ns 335.99 ns 16.83 30.81 KB 10.43
Details

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
AMD EPYC 7763 2.77GHz, 1 CPU, 4 logical and 2 physical cores
.NET SDK 10.0.203
[Host] : .NET 10.0.7 (10.0.7, 10.0.726.21808), X64 RyuJIT x86-64-v3

Job=InProcess Toolchain=InProcessEmitToolchain IterationCount=15
LaunchCount=1 WarmupCount=10

Callback Mean Error StdDev Ratio Allocated Alloc Ratio
baseline* 325.5 ns 3.85 ns 3.41 ns 0.96 1.68 KB 1.00
Mockolate 340.0 ns 6.96 ns 6.17 ns 1.00 1.68 KB 1.00
Imposter 446.1 ns 7.70 ns 7.20 ns 1.31 2.38 KB 1.42
TUnitMocks 660.1 ns 8.85 ns 8.28 ns 1.94 2.63 KB 1.56
Moq 100,467.1 ns 493.76 ns 437.70 ns 295.57 8.88 KB 5.29
NSubstitute 4,648.1 ns 19.16 ns 17.92 ns 13.67 7.74 KB 4.61
FakeItEasy 4,917.6 ns 24.71 ns 20.64 ns 14.47 6.81 KB 4.05

baseline* rows show the corresponding Mockolate benchmark from the most recent successful main branch build with results, for regression comparison.

Remove EnsureBranchExistsAsync, ParseOrCreate, BenchmarkSeedHistory and
their supporting types (ListSuccessfulRunsAsync, WorkflowRunInfo,
BenchmarkSeedRunLimit parameter). The 'benchmarks' branch and data
files have already been seeded, so PublishBenchmarkReport can now
assume both exist - matching the aweXpect surface.

If the branch or data file ever goes missing, PublishBenchmarkReport
will fail loudly (NRE on dataFile.Content / NotSupportedException on
the missing window.BENCHMARK_DATA prefix), which is the same behaviour
as aweXpect's BenchmarkReport target.
@vbreuss vbreuss enabled auto-merge (squash) May 3, 2026 12:43
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 3, 2026 12:44
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 3, 2026

@vbreuss vbreuss merged commit 4c61cdf into main May 3, 2026
21 checks passed
@vbreuss vbreuss deleted the feat/collect-benchmark-data branch May 3, 2026 12:52
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.

Comment on lines +158 to +180
BuildExtensions.GithubFile dataFile =
await BuildExtensions.ReadBranchFileAsync(BenchmarkDataPath, BenchmarkBranch, GithubToken);
BuildExtensions.GithubFile limitedFile =
await BuildExtensions.ReadBranchFileAsync(BenchmarkLimitedDataPath, BenchmarkBranch, GithubToken);

(string updated, string limited) = PageBenchmarkReportGenerator.Append(
commitInfo,
dataFile.Content,
benchmarkReports,
BenchmarkLimit);

if (string.IsNullOrWhiteSpace(updated))
{
Log.Information("No changes to publish (commit already recorded).");
return;
}

string commitMessage =
$"Update benchmark for {commitInfo.Sha.Substring(0, 8)}: {commitInfo.Message} by {commitInfo.Author}";
await BuildExtensions.WriteBranchFileAsync(BenchmarkDataPath, BenchmarkBranch, commitMessage, updated,
dataFile.Sha, GithubToken);
await BuildExtensions.WriteBranchFileAsync(BenchmarkLimitedDataPath, BenchmarkBranch, commitMessage,
limited, limitedFile.Sha, GithubToken);
Comment on lines +201 to +235
Output[] lines = GitTasks.Git("log -1").ToArray();
string commitId = null, author = null, date = null, message = null;
foreach (string line in lines.Select(x => x.Text))
{
if (commitId == null && line.StartsWith("commit "))
{
commitId = line.Substring("commit ".Length).Substring(0, 40);
continue;
}

if (author == null && line.StartsWith("Author: "))
{
author = line.Substring("Author: ".Length);
int index = author.IndexOf(" <", StringComparison.Ordinal);
if (index > 0)
{
author = author.Substring(0, index);
}

continue;
}

if (date == null && line.StartsWith("Date: "))
{
date = line.Substring("Date: ".Length);
continue;
}

if (commitId != null && author != null && date != null && !string.IsNullOrWhiteSpace(line))
{
message = line.Trim();
break;
}
}

$"{RepositoryApiBaseUrl}/contents/{path}?ref={Uri.EscapeDataString(branch)}");
if (!response.IsSuccessStatusCode)
{
return null;
Comment on lines +196 to +214
public static async Task WriteBranchFileAsync(string path, string branch, string commitMessage, string content,
string existingSha, string githubToken)
{
using HttpClient client = CreateGithubClient(githubToken);
GithubUpdateFile body = new(commitMessage, Base64Encode(content), existingSha, branch);
HttpResponseMessage response = await client.PutAsync(
$"{RepositoryApiBaseUrl}/contents/{path}",
new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"));
if (response.IsSuccessStatusCode)
{
Log.Information("Updated {Path} on branch '{Branch}'.", path, branch);
}
else
{
string responseContent = await response.Content.ReadAsStringAsync();
throw new InvalidOperationException(
$"Could not update '{path}' on branch '{branch}': {responseContent}");
}
}
Comment on lines +47 to +66
PageReportData pageReport =
JsonSerializer.Deserialize<PageReportData>(currentFileContent.Substring(FilePrefix.Length));

if (pageReport.Values.Any(r => r.Commits.Any(c => c.Sha == commitInfo.Sha)))
{
Log.Warning(
"The benchmark already has data for {Sha}: {Message} by {Author} on {Date}",
commitInfo.Sha, commitInfo.Message, commitInfo.Author, commitInfo.Date);
return (null, null);
}

Log.Debug(
"Updating benchmark report for {Sha}: {Message} by {Author} on {Date}",
commitInfo.Sha, commitInfo.Message, commitInfo.Author, commitInfo.Date);

foreach (string benchmarkReportContent in benchmarkReportsContents)
{
BenchmarkReport benchmarkReport = JsonSerializer.Deserialize<BenchmarkReport>(benchmarkReportContent);
if (!pageReport.Append(commitInfo, benchmarkReport))
{
Comment on lines +277 to +303
public Benchmark[] Benchmarks { get; init; }

public class Benchmark
{
public string Type { get; init; }
public string Method { get; init; }
public string Parameters { get; init; }
public BenchmarkStatistics Statistics { get; init; }
public BenchmarkMetrics[] Metrics { get; init; }
}

public class BenchmarkStatistics
{
public double Mean { get; init; }
}

public class BenchmarkMetrics
{
public double Value { get; init; }
public BenchmarkMetricDescriptor Descriptor { get; init; }
}

public class BenchmarkMetricDescriptor
{
public string Id { get; init; }
public string DisplayName { get; init; }
public string Unit { get; init; }
Comment on lines +106 to +130
publish-benchmark-report:
name: "Publish Benchmark Report"
needs: [ benchmarks ]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
env:
DOTNET_NOLOGO: true
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET SDKs
uses: actions/setup-dotnet@v5
with:
dotnet-version: |
10.0.x
- name: Publish benchmark report
run: ./build.sh PublishBenchmarkReport
env:
GithubToken: ${{ secrets.GITHUB_TOKEN }}
WorkflowRunId: ${{ github.run_id }}

github-actions Bot added a commit that referenced this pull request May 3, 2026
github-actions Bot added a commit that referenced this pull request May 3, 2026
@github-actions
Copy link
Copy Markdown

This is addressed in release v3.2.0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation state: released The issue is released

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants