Skip to content

Investigating: FastCloner as DeepCloner replacement#444

Draft
niemyjski wants to merge 1 commit intomainfrom
investigate-fastcloner-replacement
Draft

Investigating: FastCloner as DeepCloner replacement#444
niemyjski wants to merge 1 commit intomainfrom
investigate-fastcloner-replacement

Conversation

@niemyjski
Copy link
Member

Summary

This PR investigates replacing Force.DeepCloner with FastCloner for our DeepClone() implementation.

Changes

  • Replace Force.DeepCloner with FastCloner v3.4.4 (source imported)
  • Add Update-FastCloner.ps1 script for source import automation
  • Update ObjectExtensions.DeepClone to use FastClonerGenerator
  • Add nullable annotations ([NotNullIfNotNull]) to DeepClone extension method
  • Replace #if MODERN with #if true // MODERN for .NET 8+ targets
  • Add benchmark comparison results

Benchmark Results

Benchmark DeepCloner FastCloner Change
SmallObject 52.93 ns 132 ns +149% slower
FileSpec 299.87 ns 402 ns +34% slower
SmallObjectWithCollections 384.61 ns 490 ns +27% slower
StringArray_1000 470.19 ns 501 ns +7% slower
DynamicWithDictionary 797.00 ns 905 ns +14% slower
MediumNestedObject 1,020.19 ns 1,313 ns +29% slower
DynamicWithNestedObject 1,130.58 ns 1,315 ns +16% slower
DynamicWithArray 3,672.72 ns 3,343 ns -9% faster
LargeEventDocument_10MB 43,718.94 ns 48,415 ns +11% slower
ObjectList_100 110,525.77 ns 120,509 ns +9% slower
ObjectDictionary_50 555,214.24 ns 597,092 ns +8% slower
LargeLogBatch_10MB 3,662,330.45 ns 5,726,243 ns +56% slower

Observations

  1. FastCloner is consistently slower across 11 of 12 benchmarks (7-149% slower)
  2. Only DynamicWithArray showed improvement (9% faster)
  3. Large batch operations show the biggest regression (56% slower for 5MB log batch)
  4. Small objects show the largest relative regression (149% slower)
  5. Memory allocations are similar between the two libraries

Questions

  1. Are the published benchmarks using the source generator (FastDeepClone()) rather than reflection (DeepClone())? The README shows ~25x improvement which we're not seeing with reflection-based cloning.

  2. Is there something specific about ARM64/Apple Silicon that might cause different performance characteristics?

  3. Are there configuration options or usage patterns that would improve performance for complex nested objects with collections?

Test Environment

  • Platform: Apple M1 Max (ARM64), macOS Tahoe 26.2
  • Runtime: .NET 10.0.2 (10.0.2, 10.0.225.61305), Arm64 RyuJIT
  • FastCloner: v3.4.4 (reflection-based, MODERN code paths enabled)
  • BenchmarkDotNet: v0.15.8

Conclusion

Based on these benchmarks, FastCloner does not appear to be a performance improvement over Force.DeepCloner for our use cases. We should either:

  1. Get clarification from the FastCloner maintainer on expected performance characteristics
  2. Investigate the source generator approach if AOT cloning is acceptable
  3. Keep Force.DeepCloner as our current implementation

Note: This is a draft PR for investigation purposes. Do not merge.

@niemyjski
Copy link
Member Author

@lofcz - We evaluated FastCloner as a potential replacement for Force.DeepCloner in Foundatio and ran comprehensive benchmarks. Our results show FastCloner's reflection-based DeepClone() is 7-149% slower than Force.DeepCloner across most of our test cases.

Questions:

  1. Are the published benchmarks using the source generator (FastDeepClone()) rather than reflection-based cloning? The README shows ~25x improvement which we're not seeing.

  2. Is there something specific about ARM64/Apple Silicon that might cause different performance characteristics?

  3. Are there configuration options or usage patterns that would improve performance for complex nested objects with collections?

Our benchmark objects include:

  • Simple POCOs with primitives
  • Nested objects with UserInfo, RequestInfo, collections
  • Large error tracking events with stack traces (~155KB)
  • Batch log entries (~5MB with ~3000 entries)
  • Dynamic types with object properties containing Dictionary, nested POCOs, or mixed arrays

The full benchmark code is available in this PR. We'd appreciate any insights on expected performance characteristics or if we're missing something in our usage.

@niemyjski niemyjski changed the base branch from main to feature/deepclone January 30, 2026 18:07
@niemyjski niemyjski changed the base branch from feature/deepclone to main January 30, 2026 18:07
state.DecrementDepth();

// if UseWorkList was set during recursive cloning, process the worklist
if (!state.UseWorkList)
List<Expression> expressionList = [];

ParameterExpression from = Expression.Parameter(methodType);
ParameterExpression fromLocal = from;
ParameterExpression from = Expression.Parameter(methodType);
ParameterExpression fromLocal = from;
ParameterExpression to = Expression.Parameter(methodType);
ParameterExpression toLocal = to;
Comment on lines +52 to +59
foreach (KeyValuePair<Type, CloneBehavior> kvp in TypeBehaviors)
{
if (kvp.Value == CloneBehavior.Ignore && FastClonerSafeTypes.DefaultKnownTypes.ContainsKey(kvp.Key))
{
HasSafeTypeOverrides = true;
return;
}
}
Comment on lines +154 to +160
foreach (EventInfo evtInfo in events)
{
if (MemberIsIgnored(evtInfo))
{
details[evtInfo.Name] = evtInfo.EventHandlerType;
}
}
Comment on lines +432 to +438
foreach (EventInfo evtInfo in events)
{
if (MemberIsIgnored(evtInfo))
{
details[evtInfo.Name] = evtInfo.EventHandlerType;
}
}
Comment on lines +160 to +171
foreach (FieldInfo fi in tp.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.DeclaredOnly))
{
Type ft = fi.FieldType;
if (ft == type)
{
return true;
}
if (ft.IsArray && ft.GetElementType() == type)
{
return true;
}
}
Comment on lines +247 to +263
foreach (FieldInfo fieldInfo in GetAllTypeFields(type))
{
Type fieldType = fieldInfo.FieldType;

if (processingTypes.Contains(fieldType))
{
continue;
}

if (CanReturnSameType(fieldType, processingTypes))
{
continue;
}

knownTypes.TryAdd(type, false);
return false;
}
Comment on lines +472 to +486
if (member is FieldInfo fieldInfoForEventCheck && ignoredEventDetails.TryGetValue(fieldInfoForEventCheck.Name, out Type? evtType))
{
if (evtType == memberType)
{
if (canAssignDirect)
{
expressionList.Add(Expression.Assign(
Expression.MakeMemberAccess(toLocal, member),
Expression.Default(memberType)
));
}

continue;
}
}
Comment on lines +522 to +531
else if (member is FieldInfo fi)
{
if (ignoredEventDetails.TryGetValue(fi.Name, out Type? eventHandlerTypeFromCache))
{
if (eventHandlerTypeFromCache == fi.FieldType)
{
shouldBeIgnored = true;
}
}
}
@lofcz
Copy link

lofcz commented Jan 31, 2026

@niemyjski I'll look into your setup and tests. I'm very busy with dayjob for the upcoming two weeks, but if you can keep the PR open, I'll get to this after that. Note that I don't have a MacOS machine available, but if the performance characteristic will show similar results under Windows, I'll work on it. Note that original DeepCloner is doing a lot of things naively, that leads to subtle bugs that surface only with certain usage patterns of the cloned object. Since you are internalizing, I might also strip certain parts of FastCloner to create a "slim" distribution.

@niemyjski
Copy link
Member Author

@lofcz no worries, totally understand time commitments and no rush to close this. I appreciate your time and help.

- Replace Force.DeepCloner with FastCloner v3.4.4 (source imported)
- Add Update-FastCloner.ps1 script for source import automation
- Update ObjectExtensions.DeepClone to use FastClonerGenerator
- Add nullable annotations to DeepClone extension method
- Replace #if MODERN with #if true // MODERN for .NET 8+ targets
- Add benchmark comparison results

Benchmark results show FastCloner is 7-149% slower than DeepCloner
for our test cases, contrary to published benchmarks.
@niemyjski niemyjski force-pushed the investigate-fastcloner-replacement branch from 0711b0c to b85edeb Compare February 6, 2026 21:37
@lofcz
Copy link

lofcz commented Feb 25, 2026

Just a quick update for now, that this is being worked on, progress in https://github.com/lofcz/Foundatio/tree/feat-fast-cloner, will open a new PR once it's done. For now I have both cloners side by side in my branch for reproducible benchmarks.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants