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
114 changes: 89 additions & 25 deletions src/FastCloner.Benchmark.CI/Reporting/BenchmarkDiffReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,30 +87,54 @@ private static BaselineDiffReport CreateDiffReport(
{
if (!baselinePairs.TryGetValue(currentPair.Benchmark, out BenchmarkPair? baselinePair))
{
double? newBenchmarkTimeScore = SafeRatio(currentPair.FastCloner.MeanNanoseconds, currentPair.DeepCloner.MeanNanoseconds);
double? newBenchmarkAllocScore = SafeRatio(currentPair.FastCloner.AllocatedBytes, currentPair.DeepCloner.AllocatedBytes);

items.Add(new BaselineDiffItem(
Benchmark: currentPair.Benchmark,
BaselineMeanNanoseconds: null,
CurrentMeanNanoseconds: currentPair.FastCloner.MeanNanoseconds,
TimeDeltaRatio: null,
BaselineAllocatedBytes: null,
CurrentAllocatedBytes: currentPair.FastCloner.AllocatedBytes,
AllocDeltaRatio: null,
BaselineFastClonerMeanNanoseconds: null,
BaselineDeepClonerMeanNanoseconds: null,
CurrentFastClonerMeanNanoseconds: currentPair.FastCloner.MeanNanoseconds,
CurrentDeepClonerMeanNanoseconds: currentPair.DeepCloner.MeanNanoseconds,
BaselineTimeScore: null,
CurrentTimeScore: newBenchmarkTimeScore,
TimeScoreDeltaRatio: null,
BaselineFastClonerAllocatedBytes: null,
BaselineDeepClonerAllocatedBytes: null,
CurrentFastClonerAllocatedBytes: currentPair.FastCloner.AllocatedBytes,
CurrentDeepClonerAllocatedBytes: currentPair.DeepCloner.AllocatedBytes,
BaselineAllocScore: null,
CurrentAllocScore: newBenchmarkAllocScore,
AllocScoreDeltaRatio: null,
Status: DiffStatus.NewBenchmark));
continue;
}

double? timeDelta = SafeDelta(currentPair.FastCloner.MeanNanoseconds, baselinePair.FastCloner.MeanNanoseconds);
double? allocDelta = SafeDelta(currentPair.FastCloner.AllocatedBytes, baselinePair.FastCloner.AllocatedBytes);
double? baselineTimeScore = SafeRatio(baselinePair.FastCloner.MeanNanoseconds, baselinePair.DeepCloner.MeanNanoseconds);
double? currentTimeScore = SafeRatio(currentPair.FastCloner.MeanNanoseconds, currentPair.DeepCloner.MeanNanoseconds);
double? baselineAllocScore = SafeRatio(baselinePair.FastCloner.AllocatedBytes, baselinePair.DeepCloner.AllocatedBytes);
double? currentAllocScore = SafeRatio(currentPair.FastCloner.AllocatedBytes, currentPair.DeepCloner.AllocatedBytes);

double? timeDelta = SafeDelta(currentTimeScore, baselineTimeScore);
double? allocDelta = SafeDelta(currentAllocScore, baselineAllocScore);

DiffStatus status = ClassifyDiff(timeDelta, allocDelta, timeThreshold, allocThreshold, sameThreshold);
items.Add(new BaselineDiffItem(
Benchmark: currentPair.Benchmark,
BaselineMeanNanoseconds: baselinePair.FastCloner.MeanNanoseconds,
CurrentMeanNanoseconds: currentPair.FastCloner.MeanNanoseconds,
TimeDeltaRatio: timeDelta,
BaselineAllocatedBytes: baselinePair.FastCloner.AllocatedBytes,
CurrentAllocatedBytes: currentPair.FastCloner.AllocatedBytes,
AllocDeltaRatio: allocDelta,
BaselineFastClonerMeanNanoseconds: baselinePair.FastCloner.MeanNanoseconds,
BaselineDeepClonerMeanNanoseconds: baselinePair.DeepCloner.MeanNanoseconds,
CurrentFastClonerMeanNanoseconds: currentPair.FastCloner.MeanNanoseconds,
CurrentDeepClonerMeanNanoseconds: currentPair.DeepCloner.MeanNanoseconds,
BaselineTimeScore: baselineTimeScore,
CurrentTimeScore: currentTimeScore,
TimeScoreDeltaRatio: timeDelta,
BaselineFastClonerAllocatedBytes: baselinePair.FastCloner.AllocatedBytes,
BaselineDeepClonerAllocatedBytes: baselinePair.DeepCloner.AllocatedBytes,
CurrentFastClonerAllocatedBytes: currentPair.FastCloner.AllocatedBytes,
CurrentDeepClonerAllocatedBytes: currentPair.DeepCloner.AllocatedBytes,
BaselineAllocScore: baselineAllocScore,
CurrentAllocScore: currentAllocScore,
AllocScoreDeltaRatio: allocDelta,
Status: status));
}

Expand Down Expand Up @@ -203,13 +227,13 @@ private static string BuildCommentMarkdown(
sb.AppendLine($"- Baseline generated (UTC): `{baseline.GeneratedAtUtc:yyyy-MM-dd HH:mm:ss}`");
sb.AppendLine($"- Regression thresholds: time > `{FormatPercent(options.TimeThreshold)}`, alloc > `{FormatPercent(options.AllocThreshold)}`");
sb.AppendLine();
sb.AppendLine("| Status | Benchmark | FC Time (Baseline) | FC Time (Current) | Delta Time | FC Alloc (Baseline) | FC Alloc (Current) | Delta Alloc |");
sb.AppendLine("|---|---|---:|---:|---|---:|---:|---|");
sb.AppendLine("| Status | Benchmark | Delta Time | Delta Alloc |");
sb.AppendLine("|---|---|---|---|");

foreach (BaselineDiffItem item in diff.Items)
{
sb.AppendLine(
$"| {FormatStatus(item.Status)} | {item.Benchmark} | {FormatNanoseconds(item.BaselineMeanNanoseconds)} | {FormatNanoseconds(item.CurrentMeanNanoseconds)} | {FormatCurrentDelta(item.TimeDeltaRatio, options.SameThreshold, "faster", "slower")} | {FormatBytes(item.BaselineAllocatedBytes)} | {FormatBytes(item.CurrentAllocatedBytes)} | {FormatCurrentDelta(item.AllocDeltaRatio, options.SameThreshold, "less", "more")} |");
$"| {FormatStatus(item.Status)} | {item.Benchmark} | {FormatCurrentDelta(item.TimeScoreDeltaRatio, options.SameThreshold, "faster", "slower")} | {FormatCurrentDelta(item.AllocScoreDeltaRatio, options.SameThreshold, "less", "more")} |");
}

AppendStatusSection(sb, "Regressions", diff.Items.Where(item => item.Status == DiffStatus.Regression).ToList(), options.SameThreshold);
Expand Down Expand Up @@ -270,12 +294,20 @@ private static void AppendStatusSection(
sb.AppendLine();
foreach (BaselineDiffItem item in items)
{
string timeDelta = FormatCurrentDelta(item.TimeDeltaRatio, sameThreshold, "faster", "slower");
string allocDelta = FormatCurrentDelta(item.AllocDeltaRatio, sameThreshold, "less", "more");
string timeDelta = FormatCurrentDelta(item.TimeScoreDeltaRatio, sameThreshold, "faster", "slower");
string allocDelta = FormatCurrentDelta(item.AllocScoreDeltaRatio, sameThreshold, "less", "more");
sb.AppendLine($"- `{item.Benchmark}`: time {timeDelta}, alloc {allocDelta}");
}
}

private static double? SafeDelta(double? current, double? baseline)
{
if (current is null || baseline is null || baseline <= 0d)
return null;

return (current.Value - baseline.Value) / baseline.Value;
}

private static double? SafeDelta(double current, double baseline)
{
if (baseline <= 0)
Expand All @@ -292,6 +324,22 @@ private static void AppendStatusSection(
return (current - (double)baseline) / baseline;
}

private static double? SafeRatio(double numerator, double denominator)
{
if (denominator <= 0d)
return null;

return numerator / denominator;
}

private static double? SafeRatio(long numerator, long denominator)
{
if (denominator <= 0)
return null;

return numerator / (double)denominator;
}

private static string FormatCurrentDelta(double? ratio, double sameThreshold, string betterWord, string worseWord)
{
if (ratio is null || double.IsNaN(ratio.Value) || double.IsInfinity(ratio.Value))
Expand Down Expand Up @@ -324,6 +372,14 @@ private static string FormatBytes(long? value)
return $"{value.Value.ToString("N0", CultureInfo.InvariantCulture)} B";
}

private static string FormatScore(double? value)
{
if (value is null || double.IsNaN(value.Value) || double.IsInfinity(value.Value))
return "n/a";

return $"{value.Value.ToString("0.000", CultureInfo.InvariantCulture)}x";
}

private static string FormatPercent(double ratio)
{
return $"{(ratio * 100d).ToString("0.##", CultureInfo.InvariantCulture)}%";
Expand Down Expand Up @@ -461,12 +517,20 @@ internal sealed record BaselineDiffReport(

internal sealed record BaselineDiffItem(
string Benchmark,
double? BaselineMeanNanoseconds,
double CurrentMeanNanoseconds,
double? TimeDeltaRatio,
long? BaselineAllocatedBytes,
long CurrentAllocatedBytes,
double? AllocDeltaRatio,
double? BaselineFastClonerMeanNanoseconds,
double? BaselineDeepClonerMeanNanoseconds,
double CurrentFastClonerMeanNanoseconds,
double CurrentDeepClonerMeanNanoseconds,
double? BaselineTimeScore,
double? CurrentTimeScore,
double? TimeScoreDeltaRatio,
long? BaselineFastClonerAllocatedBytes,
long? BaselineDeepClonerAllocatedBytes,
long CurrentFastClonerAllocatedBytes,
long CurrentDeepClonerAllocatedBytes,
double? BaselineAllocScore,
double? CurrentAllocScore,
double? AllocScoreDeltaRatio,
DiffStatus Status);

internal enum DiffStatus
Expand Down
9 changes: 8 additions & 1 deletion src/FastCloner.Internalization.Builder/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,8 @@ private static void PrintUsage()
Example:
MODERN=true;NET8_0_OR_GREATER=true
MODERN=true;SOMETHING=random_text
Default replacements:
MODERN_10=NET10_0_OR_GREATER
Special values:
true / false (recognized booleans)
Non-boolean values are used as direct token replacement in #if expressions.
Expand Down Expand Up @@ -389,6 +391,11 @@ internal enum PublicApiMode

internal sealed class BuildOptions
{
private static readonly IReadOnlyDictionary<string, string> DefaultPreprocessor = new Dictionary<string, string>(StringComparer.Ordinal)
{
["MODERN_10"] = "NET10_0_OR_GREATER"
};

public required string RootNamespace { get; init; }
public required string Output { get; init; }
public required string InputRoot { get; init; }
Expand Down Expand Up @@ -475,7 +482,7 @@ public HashSet<string> GetPreprocessorSymbols()

private static Dictionary<string, string> ParsePreprocessor(string raw)
{
Dictionary<string, string> map = new(StringComparer.Ordinal);
Dictionary<string, string> map = new(DefaultPreprocessor, StringComparer.Ordinal);
if (String.IsNullOrWhiteSpace(raw))
return map;

Expand Down
52 changes: 52 additions & 0 deletions src/FastCloner.Tests/CircularTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using FastCloner.Code;

namespace FastCloner.Tests;

[TestFixture(Low)]
Expand All @@ -21,6 +23,12 @@ public class C1
public C1 A { get; set; }
}

public sealed class SealedLoop
{
public int Value { get; set; }
public SealedLoop? Next { get; set; }
}

[Test]
public void SimpleLoop_Should_Be_Handled()
{
Expand Down Expand Up @@ -71,6 +79,19 @@ public void Object_Own_Loop_Should_Be_Handled()
Assert.That(cloned.A, Is.EqualTo(cloned));
}

[Test]
public void Sealed_Object_Own_Loop_Should_Be_Handled()
{
SealedLoop root = new SealedLoop { Value = 1 };
root.Next = root;

SealedLoop cloned = root.DeepClone();

Assert.That(cloned, Is.Not.SameAs(root));
Assert.That(cloned.Next, Is.SameAs(cloned));
Assert.That(cloned.Value, Is.EqualTo(1));
}

[Test]
public void Array_Of_Same_Objects_Should_Be_Cloned()
{
Expand All @@ -97,4 +118,35 @@ public void StructWrappedReferenceLoop_Should_Be_Handled()
Assert.That(cloned.W.Ref, Is.Not.Null);
Assert.That(cloned.W.Ref, Is.EqualTo(cloned));
}

[Test]
public void Internal_CloneClassInternal_Should_Reset_CallDepth_After_WorkList_Switch()
{
C1 root = new C1
{
F = 1,
A = new C1
{
F = 2,
A = new C1 { F = 3 }
}
};

int previousMaxRecursionDepth = FastCloner.MaxRecursionDepth;
FastCloner.MaxRecursionDepth = 1;
FastCloneState state = FastCloneState.Rent();
Comment thread
lofcz marked this conversation as resolved.
try
{
C1? clone = (C1?)FastClonerGenerator.CloneClassInternal(root, state);

Assert.That(clone, Is.Not.Null);
Assert.That(state.UseWorkList, Is.True);
Assert.That(state.CurrentDepth, Is.EqualTo(0));
}
finally
{
FastCloneState.Return(state);
FastCloner.MaxRecursionDepth = previousMaxRecursionDepth;
}
}
}
78 changes: 78 additions & 0 deletions src/FastCloner.Tests/DynamicMaxRecursionDepthTests.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
using FastCloner.Code;

namespace FastCloner.Tests;

[TestFixture]
public class DynamicMaxRecursionDepthTests
{
public sealed class ExactNode
{
public int Value { get; set; }
public ExactNode? Child { get; set; }
}

public class A
{
public List<B> Bs = [];
Expand Down Expand Up @@ -120,4 +128,74 @@ public void TestDeepObjectGraph_1500Levels_WithMRD1000()

Assert.That(level, Is.GreaterThan(maxRecursionDepth), $"Verified {level} levels");
}

[Test]
public void TestDeepExactObjectGraph_1500Levels_WithMRD1()
{
const int nestLevel = 1500;

ExactNode root = new ExactNode { Value = 0 };
ExactNode current = root;

for (int i = 1; i <= nestLevel; i++)
{
ExactNode next = new ExactNode { Value = i };
current.Child = next;
current = next;
}

FastCloner.MaxRecursionDepth = 1;
ExactNode? clone = FastCloner.DeepClone(root);

Assert.That(clone, Is.Not.Null);
Assert.That(clone, Is.Not.SameAs(root));

ExactNode? originalCurrent = root;
ExactNode? cloneCurrent = clone;
int verifiedLevels = 0;
while (originalCurrent is not null && cloneCurrent is not null)
{
Assert.That(cloneCurrent, Is.Not.SameAs(originalCurrent), $"Node at level {verifiedLevels} should be cloned");
Assert.That(cloneCurrent.Value, Is.EqualTo(originalCurrent.Value), $"Value at level {verifiedLevels} should match");

originalCurrent = originalCurrent.Child;
cloneCurrent = cloneCurrent.Child;
verifiedLevels++;
}

Assert.That(verifiedLevels, Is.EqualTo(nestLevel + 1));
Assert.That(cloneCurrent, Is.Null);
Assert.That(originalCurrent, Is.Null);
}

[Test]
public void Internal_CloneClassInternal_Should_Reset_CallDepth_After_WorkList_Switch()
{
ExactNode root = new ExactNode
{
Value = 1,
Child = new ExactNode
{
Value = 2,
Child = new ExactNode { Value = 3 }
}
};

int previousMaxRecursionDepth = FastCloner.MaxRecursionDepth;
FastCloner.MaxRecursionDepth = 1;
FastCloneState state = FastCloneState.Rent();
try
{
ExactNode? clone = (ExactNode?)FastClonerGenerator.CloneClassInternal(root, state);

Assert.That(clone, Is.Not.Null);
Assert.That(state.UseWorkList, Is.True);
Assert.That(state.CurrentDepth, Is.EqualTo(0));
}
finally
{
FastCloneState.Return(state);
FastCloner.MaxRecursionDepth = previousMaxRecursionDepth;
}
}
}
3 changes: 1 addition & 2 deletions src/FastCloner/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("FastCloner.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100bd882e7dcc8a5cfdd6e2193c66bb144ab67e83964b825035b4b05ccdc40a5a7218a497799f98f98e628c38492fa227ad1579c7d701934ea2e30459a4dabfcfa498fc9f4dc6d3e3f118e4df6615aced3da480ea45d30832ddbfd56acebc957ee6345944d2e9b82705a725276146baadf08cf7a8612fd4f3ceb8b8ec9529b975c2")]
[assembly: InternalsVisibleTo("FastCloner.Contrib, PublicKey=0024000004800000940000000602000000240000525341310004000001000100bd882e7dcc8a5cfdd6e2193c66bb144ab67e83964b825035b4b05ccdc40a5a7218a497799f98f98e628c38492fa227ad1579c7d701934ea2e30459a4dabfcfa498fc9f4dc6d3e3f118e4df6615aced3da480ea45d30832ddbfd56acebc957ee6345944d2e9b82705a725276146baadf08cf7a8612fd4f3ceb8b8ec9529b975c2")]
namespace FastCloner;
[assembly: InternalsVisibleTo("FastCloner.Contrib, PublicKey=0024000004800000940000000602000000240000525341310004000001000100bd882e7dcc8a5cfdd6e2193c66bb144ab67e83964b825035b4b05ccdc40a5a7218a497799f98f98e628c38492fa227ad1579c7d701934ea2e30459a4dabfcfa498fc9f4dc6d3e3f118e4df6615aced3da480ea45d30832ddbfd56acebc957ee6345944d2e9b82705a725276146baadf08cf7a8612fd4f3ceb8b8ec9529b975c2")]
1 change: 1 addition & 0 deletions src/FastCloner/Code/FastCloneState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
private int workCount;
public bool UseWorkList { get; set; }
private int callDepth;
internal int CurrentDepth => callDepth;
private readonly Type?[] metadataTypes = new Type?[MetadataCacheSize];
private readonly FastClonerCache.TypeCloneMetadata[] metadataValues = new FastClonerCache.TypeCloneMetadata[MetadataCacheSize];
private int metadataWriteIndex;
Expand Down Expand Up @@ -295,7 +296,7 @@

public MiniDictionary() : this(DefaultCapacity) { }

public MiniDictionary(int capacity)

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-netstandard20

Non-nullable field 'entries' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-netstandard20

Non-nullable field 'buckets' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Non-nullable field 'entries' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

Non-nullable field 'buckets' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Non-nullable field 'entries' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-and-test (ubuntu-latest)

Non-nullable field 'buckets' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-net46

Non-nullable field 'entries' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-net46

Non-nullable field 'buckets' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

Non-nullable field 'entries' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

Non-nullable field 'buckets' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / Benchmarks (ubuntu-latest)

Non-nullable field 'entries' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.

Check warning on line 299 in src/FastCloner/Code/FastCloneState.cs

View workflow job for this annotation

GitHub Actions / Benchmarks (ubuntu-latest)

Non-nullable field 'buckets' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the field as nullable.
{
int size = RoundUpToPowerOf2(capacity < DefaultCapacity ? DefaultCapacity : capacity);
Initialize(size);
Expand Down
Loading
Loading