Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .bench-harness-current/BenchHarness.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\src\FastCloner\FastCloner.csproj" />
</ItemGroup>
</Project>
34 changes: 34 additions & 0 deletions .bench-harness-current/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System.Diagnostics;
using FastCloner;

var results = new List<(string Name, double Ms, double NsPerOp)>();
Measure("SmallObject x100000", CreateSmallObject(), 2000, 100000, static x => FastCloner.FastCloner.DeepClone((SmallObject)x)!);
Measure("StringArray1000 x20000", CreateStringArray(1000), 500, 20000, static x => FastCloner.FastCloner.DeepClone((string[])x)!);
Measure("Dictionary50 x5000", CreateDictionary(50), 200, 5000, static x => FastCloner.FastCloner.DeepClone((Dictionary<string, SmallObject>)x)!);
foreach (var (name, ms, nsPerOp) in results)
Console.WriteLine($"{name}|{ms:F2}|{nsPerOp:F1}");

void Measure(string name, object value, int warmup, int iterations, Func<object, object> clone)
{
for (var i = 0; i < warmup; i++) _ = clone(value);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++) _ = clone(value);
sw.Stop();
results.Add((name, sw.Elapsed.TotalMilliseconds, sw.Elapsed.TotalMilliseconds * 1_000_000d / iterations));
}

static SmallObject CreateSmallObject() => new() { Id = 123, Name = "small-object-name", CreatedAt = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), IsActive = true, Score = 42.5 };
static string[] CreateStringArray(int count) { var arr = new string[count]; for (var i = 0; i < count; i++) arr[i] = $"value-{i}"; return arr; }
static Dictionary<string, SmallObject> CreateDictionary(int count) { var dict = new Dictionary<string, SmallObject>(count); for (var i = 0; i < count; i++) dict[$"key-{i}"] = new SmallObject { Id = i, Name = $"item-{i}", CreatedAt = new DateTime(2025, 1, 1).AddMinutes(i), IsActive = (i & 1) == 0, Score = i * 1.25 }; return dict; }

public sealed class SmallObject
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public bool IsActive { get; set; }
public double Score { get; set; }
}
118 changes: 101 additions & 17 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ on:

env:
DOTNET_VERSION: "10.0.103"
BASELINE_BRANCH: "next"

Comment on lines 23 to 26

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

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

The workflow sets BASELINE_BRANCH to next, but the benchmark CI README describes baseline artifacts and slow-path cloning coming from master. Please align the workflow/env var with the documented baseline branch (or update the README to match the intended branch).

Copilot uses AI. Check for mistakes.
permissions:
contents: read
Expand Down Expand Up @@ -68,39 +69,45 @@ jobs:

- name: Restore benchmark project
if: steps.run_gate.outputs.should_run == 'true'
run: dotnet restore src/FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj
working-directory: src
run: dotnet restore FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj

- name: Run deep clone benchmarks
if: steps.run_gate.outputs.should_run == 'true'
working-directory: src
shell: pwsh
run: >
dotnet run -c Release --project src/FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj -- --filter *DeepCloneBenchmarks*
dotnet run -c Release --project FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj -- --filter *DeepCloneBenchmarks*

- name: Resolve benchmark CSV path
if: steps.run_gate.outputs.should_run == 'true'
shell: pwsh
run: |
$csv = Get-ChildItem -Path "BenchmarkDotNet.Artifacts/results" -Filter "*DeepCloneBenchmarks-report.csv" -Recurse |
$csv = Get-ChildItem -Path "src/BenchmarkDotNet.Artifacts/results" -Filter "*DeepCloneBenchmarks-report.csv" -Recurse |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $csv) {
throw "Could not find BenchmarkDotNet CSV output for DeepCloneBenchmarks."
}

"BENCHMARK_CSV=$($csv.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append
"RESULT_DIR=benchmark-results/${{ matrix.os }}" | Out-File -FilePath $env:GITHUB_ENV -Append
"RESULT_DIR=$($env:GITHUB_WORKSPACE)/src/benchmark-results/${{ matrix.os }}" | Out-File -FilePath $env:GITHUB_ENV -Append

- name: Download latest baseline from next
- name: Download latest baseline artifact
if: steps.run_gate.outputs.should_run == 'true' && github.event_name == 'pull_request'
id: baseline_lookup
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$repo = "${{ github.repository }}"
$workflowFile = "benchmark.yml"
$artifactName = "deepclone-baseline-${{ matrix.os }}"
$baselineBranch = "${{ env.BASELINE_BRANCH }}"

"baseline_branch=$baselineBranch" | Out-File -FilePath $env:GITHUB_OUTPUT -Append

$runsResponse = gh api "repos/$repo/actions/workflows/$workflowFile/runs?branch=next&event=push&status=success&per_page=50"
$runsResponse = gh api "repos/$repo/actions/workflows/$workflowFile/runs?branch=$baselineBranch&event=push&status=success&per_page=50"
$runs = ($runsResponse | ConvertFrom-Json).workflow_runs
$baselineRunId = $null

Expand All @@ -121,7 +128,9 @@ jobs:
}

if (-not $baselineRunId) {
Write-Host "No baseline artifact found for '$artifactName'."
Write-Host "No baseline artifact found for '$artifactName' on branch '$baselineBranch'. Falling back to slow-path baseline generation."
"baseline_found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"baseline_needs_slow_path=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
exit 0
}

Expand All @@ -132,20 +141,68 @@ jobs:
if ($baselineJson) {
"BASELINE_JSON=$($baselineJson.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Append
Write-Host "Using baseline: $($baselineJson.FullName)"
"baseline_found=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"baseline_needs_slow_path=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
} else {
Write-Host "Downloaded baseline artifact but current-normalized.json was not found."
Write-Host "Downloaded baseline artifact but current-normalized.json was not found. Falling back to slow-path baseline generation."
"baseline_found=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
"baseline_needs_slow_path=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
}

- name: Clone baseline branch for slow-path comparison
if: steps.run_gate.outputs.should_run == 'true' && github.event_name == 'pull_request' && steps.baseline_lookup.outputs.baseline_needs_slow_path == 'true'
shell: pwsh
run: |
$repo = "${{ github.repository }}"
$baselineBranch = "${{ steps.baseline_lookup.outputs.baseline_branch }}"

if (Test-Path baseline-repo) {
Remove-Item -Recurse -Force baseline-repo
}

git clone --depth 1 --branch $baselineBranch "https://github.com/$repo.git" baseline-repo

- name: Restore slow-path baseline benchmark project
if: steps.run_gate.outputs.should_run == 'true' && github.event_name == 'pull_request' && steps.baseline_lookup.outputs.baseline_needs_slow_path == 'true'
working-directory: baseline-repo/src
run: dotnet restore FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj

- name: Run slow-path baseline benchmarks
if: steps.run_gate.outputs.should_run == 'true' && github.event_name == 'pull_request' && steps.baseline_lookup.outputs.baseline_needs_slow_path == 'true'
working-directory: baseline-repo/src
shell: pwsh
run: >
dotnet run -c Release --project FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj -- --filter *DeepCloneBenchmarks*

- name: Generate slow-path baseline normalized report
if: steps.run_gate.outputs.should_run == 'true' && github.event_name == 'pull_request' && steps.baseline_lookup.outputs.baseline_needs_slow_path == 'true'
working-directory: baseline-repo/src
shell: pwsh
run: |
$csv = Get-ChildItem -Path "BenchmarkDotNet.Artifacts/results" -Filter "*DeepCloneBenchmarks-report.csv" -Recurse |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $csv) {
throw "Could not find BenchmarkDotNet CSV output for slow-path baseline benchmarks."
}

dotnet run -c Release --project FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj -- --report --csv $csv.FullName --normalized-json benchmark-results/current-normalized.json

$baselineJson = Resolve-Path "benchmark-results/current-normalized.json"
"BASELINE_JSON=$($baselineJson.Path)" | Out-File -FilePath $env:GITHUB_ENV -Append
Write-Host "Generated slow-path baseline: $($baselineJson.Path)"

- name: Generate normalized report and diff
if: steps.run_gate.outputs.should_run == 'true'
working-directory: src
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path $env:RESULT_DIR | Out-Null

$args = @(
"run",
"-c", "Release",
"--project", "src/FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj",
"--project", "FastCloner.Benchmark.CI/FastCloner.Benchmark.CI.csproj",
"--",
"--report",
"--csv", $env:BENCHMARK_CSV,
Expand Down Expand Up @@ -177,8 +234,8 @@ jobs:
with:
name: deepclone-results-${{ matrix.os }}
path: |
benchmark-results/${{ matrix.os }}/**
BenchmarkDotNet.Artifacts/results/*DeepCloneBenchmarks*
src/benchmark-results/${{ matrix.os }}/**
src/BenchmarkDotNet.Artifacts/results/*DeepCloneBenchmarks*
if-no-files-found: warn
retention-days: 30

Expand All @@ -187,7 +244,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: deepclone-baseline-${{ matrix.os }}
path: benchmark-results/${{ matrix.os }}/current-normalized.json
path: src/benchmark-results/${{ matrix.os }}/current-normalized.json
if-no-files-found: error
retention-days: 30

Expand All @@ -198,15 +255,42 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
$commentPath = "$env:RESULT_DIR/pr-comment.md"
if (-not (Test-Path $commentPath)) {
throw "PR comment report not found: $commentPath"
}

$marker = "<!-- deepclone-benchmark-report -->"
$body = $marker + "`n" + (Get-Content $commentPath -Raw)
$repo = "${{ github.repository }}"
$prNumber = "${{ github.event.pull_request.number }}"

if (Test-Path $commentPath) {
$body = $marker + "`n" + (Get-Content $commentPath -Raw)
} else {
$summaryPath = "$env:RESULT_DIR/summary.md"
$currentReportPath = "$env:RESULT_DIR/current-report.md"

$fallback = @(
'## Deep Clone Benchmarks'
''
'- OS: `${{ matrix.os }}`'
'- Detailed PR benchmark report was not generated for this run.'
)

if (Test-Path $summaryPath) {
$fallback += ""
$fallback += "### Summary"
$fallback += ""
$fallback += (Get-Content $summaryPath -Raw)
} elseif (Test-Path $currentReportPath) {
$fallback += ""
$fallback += "### Current FastCloner vs DeepCloner"
$fallback += ""
$fallback += (Get-Content $currentReportPath -Raw)
} else {
$fallback += ""
$fallback += "Benchmark artifacts should still be attached to this workflow run."
}

$body = $marker + "`n" + ($fallback -join "`n")
Write-Host "PR comment report not found at $commentPath. Posting fallback benchmark comment instead."
}

$comments = gh api "repos/$repo/issues/$prNumber/comments?per_page=100" | ConvertFrom-Json
$existing = $comments | Where-Object { $_.body -like "*$marker*" } | Select-Object -First 1

Expand Down
15 changes: 10 additions & 5 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,16 @@ jobs:
node-version: ${{ env.NODE_VERSION }}

- name: Restore dependencies
run: dotnet restore src/FastClonerCi.slnf
working-directory: src
run: dotnet restore FastClonerCi.slnf

- name: Build
run: dotnet build src/FastClonerCi.slnf --no-restore
working-directory: src
run: dotnet build FastClonerCi.slnf --no-restore

- name: Test
run: dotnet test src/FastClonerCi.slnf --no-build --verbosity normal
working-directory: src
run: dotnet test --solution FastClonerCi.slnf --no-build --verbosity normal
Comment thread
lofcz marked this conversation as resolved.

- name: Update GitHub status check
if: always()
Expand Down Expand Up @@ -73,7 +76,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}

- name: Build FastCloner for netstandard2.0
run: dotnet build src/FastCloner/ --framework netstandard2.0
working-directory: src
run: dotnet build FastCloner/ --framework netstandard2.0

- name: Update GitHub status check
if: always()
Expand Down Expand Up @@ -104,7 +108,8 @@ jobs:
node-version: ${{ env.NODE_VERSION }}

- name: Build FastCloner for net46
run: dotnet build src/FastCloner/ --framework net46
working-directory: src
run: dotnet build FastCloner/ --framework net46

- name: Update GitHub status check
if: always()
Expand Down
Loading
Loading