Skip to content

Commit 40e243b

Browse files
niemyjskilofcz
authored andcommitted
Investigate FastCloner as DeepCloner replacement
- 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.
1 parent 318fa1a commit 40e243b

25 files changed

+4776
-36
lines changed

benchmarks/DEEPCLONE_BENCHMARK_RESULTS.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,80 @@ Apple M1 Max, 1 CPU, 10 logical and 10 physical cores
134134
- `DeepClone_StringArray_1000`: Array of 1000 strings
135135
- `DeepClone_ObjectList_100`: List of 100 medium nested objects
136136
- `DeepClone_ObjectDictionary_50`: Dictionary of 50 event documents
137+
138+
---
139+
140+
## FastCloner v3.4.4 Evaluation
141+
142+
We evaluated [FastCloner](https://github.com/lofcz/FastCloner) as a potential replacement for Force.DeepCloner.
143+
144+
### Results Summary (FastCloner v3.4.4)
145+
146+
| Benchmark | Mean | Allocated | Use Case |
147+
|-----------|------|-----------|----------|
148+
| **DeepClone_SmallObject** | 132 ns | 144 B | Simple cache entries |
149+
| **DeepClone_FileSpec** | 402 ns | 1,072 B | File storage metadata |
150+
| **DeepClone_SmallObjectWithCollections** | 490 ns | 1,256 B | Config/metadata caching |
151+
| **DeepClone_StringArray_1000** | 501 ns | 8,057 B | String collections |
152+
| **DeepClone_DynamicWithDictionary** | 905 ns | 3,224 B | JSON-like dynamic data |
153+
| **DeepClone_MediumNestedObject** | 1,313 ns | 3,352 B | Typical queue messages |
154+
| **DeepClone_DynamicWithNestedObject** | 1,315 ns | 3,464 B | Nested dynamic objects |
155+
| **DeepClone_DynamicWithArray** | 3,343 ns | 8,984 B | Mixed-type arrays |
156+
| **DeepClone_LargeEventDocument_10MB** | 48,415 ns | 154,888 B | Error tracking events |
157+
| **DeepClone_ObjectList_100** | 120,509 ns | 374,672 B | Batch processing |
158+
| **DeepClone_ObjectDictionary_50** | 597,092 ns | 631,619 B | Keyed collections |
159+
| **DeepClone_LargeLogBatch_10MB** | 5,726,243 ns | 5,251,027 B | Bulk log ingestion |
160+
161+
### Comparison: Force.DeepCloner vs FastCloner
162+
163+
| Benchmark | DeepCloner | FastCloner | Change |
164+
|-----------|------------|------------|--------|
165+
| SmallObject | 52.93 ns | 132 ns | **+149% slower** |
166+
| FileSpec | 299.87 ns | 402 ns | **+34% slower** |
167+
| SmallObjectWithCollections | 384.61 ns | 490 ns | **+27% slower** |
168+
| StringArray_1000 | 470.19 ns | 501 ns | +7% slower |
169+
| DynamicWithDictionary | 797.00 ns | 905 ns | +14% slower |
170+
| MediumNestedObject | 1,020.19 ns | 1,313 ns | **+29% slower** |
171+
| DynamicWithNestedObject | 1,130.58 ns | 1,315 ns | +16% slower |
172+
| DynamicWithArray | 3,672.72 ns | 3,343 ns | **-9% faster** |
173+
| LargeEventDocument_10MB | 43,718.94 ns | 48,415 ns | +11% slower |
174+
| ObjectList_100 | 110,525.77 ns | 120,509 ns | +9% slower |
175+
| ObjectDictionary_50 | 555,214.24 ns | 597,092 ns | +8% slower |
176+
| LargeLogBatch_10MB | 3,662,330.45 ns | 5,726,243 ns | **+56% slower** |
177+
178+
### Conclusion
179+
180+
**FastCloner is consistently slower than DeepCloner** for our benchmark suite:
181+
182+
- **Small objects**: 27-149% slower
183+
- **Medium objects**: 9-29% slower
184+
- **Large objects**: 8-56% slower
185+
- **Only exception**: DynamicWithArray is 9% faster
186+
187+
This contradicts FastCloner's published benchmarks which show ~25x improvement over DeepCloner. The published benchmarks likely use the source generator (`FastDeepClone()`) rather than reflection-based cloning.
188+
189+
### Raw Results (FastCloner v3.4.4)
190+
191+
```
192+
BenchmarkDotNet v0.15.8, macOS Tahoe 26.2 (25C56) [Darwin 25.2.0]
193+
Apple M1 Max, 1 CPU, 10 logical and 10 physical cores
194+
.NET SDK 10.0.102
195+
[Host] : .NET 10.0.2 (10.0.2, 10.0.225.61305), Arm64 RyuJIT armv8.0-a
196+
DefaultJob : .NET 10.0.2 (10.0.2, 10.0.225.61305), Arm64 RyuJIT armv8.0-a
197+
198+
199+
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
200+
|------------------------------------- |---------------:|--------------:|-------------:|--------:|--------:|---------:|---------:|---------:|----------:|------------:|
201+
| DeepClone_StringArray_1000 | 501.2 ns | 9.63 ns | 10.71 ns | 0.010 | 0.00 | 1.2836 | - | - | 8057 B | 0.052 |
202+
| DeepClone_ObjectList_100 | 120,508.5 ns | 2,405.40 ns | 5,177.89 ns | 2.489 | 0.11 | 59.5703 | 19.7754 | - | 374672 B | 2.419 |
203+
| DeepClone_ObjectDictionary_50 | 597,091.5 ns | 11,791.91 ns | 11,030.16 ns | 12.333 | 0.23 | 96.6797 | 44.9219 | 17.5781 | 631619 B | 4.078 |
204+
| DeepClone_DynamicWithDictionary | 905.2 ns | 17.14 ns | 19.05 ns | 0.019 | 0.00 | 0.5131 | 0.0038 | - | 3224 B | 0.021 |
205+
| DeepClone_DynamicWithNestedObject | 1,314.5 ns | 26.25 ns | 64.89 ns | 0.027 | 0.00 | 0.5512 | 0.0038 | - | 3464 B | 0.022 |
206+
| DeepClone_DynamicWithArray | 3,342.6 ns | 21.09 ns | 16.47 ns | 0.069 | 0.00 | 1.4305 | 0.0229 | - | 8984 B | 0.058 |
207+
| DeepClone_LargeEventDocument_10MB | 48,415.0 ns | 385.12 ns | 300.68 ns | 1.000 | 0.01 | 24.5972 | 4.5166 | - | 154888 B | 1.000 |
208+
| DeepClone_LargeLogBatch_10MB | 5,726,242.8 ns | 109,699.46 ns | 85,646.12 ns | 118.278 | 1.84 | 562.5000 | 257.8125 | 101.5625 | 5251027 B | 33.902 |
209+
| DeepClone_MediumNestedObject | 1,312.7 ns | 25.56 ns | 39.80 ns | 0.027 | 0.00 | 0.5341 | 0.0038 | - | 3352 B | 0.022 |
210+
| DeepClone_FileSpec | 401.5 ns | 6.40 ns | 6.57 ns | 0.008 | 0.00 | 0.1707 | - | - | 1072 B | 0.007 |
211+
| DeepClone_SmallObject | 132.1 ns | 2.60 ns | 3.56 ns | 0.003 | 0.00 | 0.0229 | - | - | 144 B | 0.001 |
212+
| DeepClone_SmallObjectWithCollections | 490.3 ns | 3.48 ns | 2.71 ns | 0.010 | 0.00 | 0.1993 | - | - | 1256 B | 0.008 |
213+
```

build/Update-FastCloner.ps1

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
$work_dir = Resolve-Path "$PSScriptRoot"
2+
$src_dir = Resolve-Path "$PSScriptRoot/../src/Foundatio"
3+
4+
# Standard using directives and nullable enable to prepend to files
5+
$standardUsings = @"
6+
#nullable enable
7+
using System;
8+
using System.Collections.Generic;
9+
using System.Collections.Concurrent;
10+
using System.Linq;
11+
using System.Linq.Expressions;
12+
using System.Net.Http;
13+
using System.Reflection;
14+
using System.Runtime.CompilerServices;
15+
using System.Threading;
16+
17+
"@
18+
19+
Function UpdateFastCloner {
20+
param([string]$version)
21+
22+
$sourceUrl = "https://github.com/lofcz/FastCloner/archive/refs/tags/v$version.zip"
23+
$name = "FastCloner"
24+
25+
$zipPath = Join-Path $work_dir "$name.zip"
26+
$extractPath = Join-Path $work_dir $name
27+
$destPath = Join-Path $src_dir $name
28+
29+
# Download and extract
30+
If (Test-Path $zipPath) { Remove-Item $zipPath }
31+
Invoke-WebRequest $sourceUrl -OutFile $zipPath
32+
33+
If (Test-Path $extractPath) { Remove-Item $extractPath -Recurse -Force }
34+
Expand-Archive -Path $zipPath -DestinationPath $extractPath
35+
Remove-Item $zipPath
36+
37+
# Clean destination
38+
If (Test-Path $destPath) { Remove-Item $destPath -Recurse -Force }
39+
40+
$dir = (Get-ChildItem $extractPath | Select-Object -First 1).FullName
41+
42+
# Create directory structure
43+
New-Item $destPath -Type Directory | Out-Null
44+
New-Item (Join-Path $destPath "Code") -Type Directory | Out-Null
45+
46+
# Copy LICENSE
47+
Copy-Item (Join-Path $dir "LICENSE") -Destination $destPath -Force
48+
49+
# Copy and transform FastCloner.cs (skip FastClonerExtensions.cs - we have our own)
50+
$srcFastClonerPath = Join-Path $dir "src/FastCloner"
51+
Get-ChildItem -Path $srcFastClonerPath -Filter "FastCloner.cs" |
52+
Foreach-Object {
53+
$c = ($_ | Get-Content -Raw)
54+
$c = $c -replace 'namespace FastCloner;','namespace Foundatio.FastCloner;'
55+
$c = $c -replace 'using FastCloner\.Code;','using Foundatio.FastCloner.Code;'
56+
# Remove existing using directives that we're adding
57+
$c = $c -replace 'using System;[\r\n]+', ''
58+
$c = $c -replace 'using System\.Collections\.Generic;[\r\n]+', ''
59+
$c = $c -replace 'using System\.Collections\.Concurrent;[\r\n]+', ''
60+
$c = $c -replace 'using System\.Linq;[\r\n]+', ''
61+
$c = $c -replace 'using System\.Linq\.Expressions;[\r\n]+', ''
62+
$c = $c -replace 'using System\.Reflection;[\r\n]+', ''
63+
$c = $c -replace 'using System\.Runtime\.CompilerServices;[\r\n]+', ''
64+
$c = $c -replace 'using System\.Threading;[\r\n]+', ''
65+
# Make all public types internal (we only expose via ObjectExtensions.DeepClone)
66+
$c = $c -replace 'public static class','internal static class'
67+
$c = $c -replace 'public class','internal class'
68+
$c = $c -replace 'public enum','internal enum'
69+
$c = $c -replace 'public struct','internal struct'
70+
# Replace MODERN preprocessor directives with always-true/false for .NET 8+
71+
# Foundatio only targets net8.0 and net10.0, so MODERN is always true
72+
$c = $c -replace '#if MODERN','#if true // MODERN'
73+
$c = $c -replace '#if !MODERN','#if false // !MODERN'
74+
$c = $c -replace '#elif MODERN','#elif true // MODERN'
75+
$c = $c -replace '#elif !MODERN','#elif false // !MODERN'
76+
# Add standard usings at the top
77+
$c = $standardUsings + $c
78+
$c | Set-Content (Join-Path $destPath $_.Name)
79+
}
80+
81+
# Copy and transform Code/*.cs files
82+
$srcCodePath = Join-Path $dir "src/FastCloner/Code"
83+
$destCodePath = Join-Path $destPath "Code"
84+
Get-ChildItem -Path $srcCodePath -Filter *.cs |
85+
Foreach-Object {
86+
$c = ($_ | Get-Content -Raw)
87+
$c = $c -replace 'namespace FastCloner\.Code;','namespace Foundatio.FastCloner.Code;'
88+
$c = $c -replace 'namespace FastCloner;','namespace Foundatio.FastCloner;'
89+
$c = $c -replace 'using FastCloner\.Code;','using Foundatio.FastCloner.Code;'
90+
$c = $c -replace 'using FastCloner;','using Foundatio.FastCloner;'
91+
# Remove existing using directives that we're adding
92+
$c = $c -replace 'using System;[\r\n]+', ''
93+
$c = $c -replace 'using System\.Collections\.Generic;[\r\n]+', ''
94+
$c = $c -replace 'using System\.Collections\.Concurrent;[\r\n]+', ''
95+
$c = $c -replace 'using System\.Linq;[\r\n]+', ''
96+
$c = $c -replace 'using System\.Linq\.Expressions;[\r\n]+', ''
97+
$c = $c -replace 'using System\.Reflection;[\r\n]+', ''
98+
$c = $c -replace 'using System\.Runtime\.CompilerServices;[\r\n]+', ''
99+
$c = $c -replace 'using System\.Threading;[\r\n]+', ''
100+
# Make all public types internal (we only expose via ObjectExtensions.DeepClone)
101+
$c = $c -replace 'public static class','internal static class'
102+
$c = $c -replace 'public class','internal class'
103+
$c = $c -replace 'public enum','internal enum'
104+
$c = $c -replace 'public struct','internal struct'
105+
# Replace MODERN preprocessor directives with always-true/false for .NET 8+
106+
# Foundatio only targets net8.0 and net10.0, so MODERN is always true
107+
$c = $c -replace '#if MODERN','#if true // MODERN'
108+
$c = $c -replace '#if !MODERN','#if false // !MODERN'
109+
$c = $c -replace '#elif MODERN','#elif true // MODERN'
110+
$c = $c -replace '#elif !MODERN','#elif false // !MODERN'
111+
# Add standard usings at the top
112+
$c = $standardUsings + $c
113+
$c | Set-Content (Join-Path $destCodePath $_.Name)
114+
}
115+
116+
# Cleanup
117+
Remove-Item $extractPath -Recurse -Force
118+
}
119+
120+
UpdateFastCloner "3.4.4"

src/Foundatio/DeepCloner/Helpers/ShallowClonerGenerator.cs

Lines changed: 0 additions & 31 deletions
This file was deleted.
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
using Foundatio.Force.DeepCloner.Helpers;
1+
using System.Diagnostics.CodeAnalysis;
2+
using Foundatio.FastCloner.Code;
23

34
namespace Foundatio.Utility;
45

56
public static class ObjectExtensions
67
{
7-
public static T DeepClone<T>(this T original)
8+
[return: NotNullIfNotNull(nameof(original))]
9+
public static T? DeepClone<T>(this T? original)
810
{
9-
return DeepClonerGenerator.CloneObject(original);
11+
return FastClonerGenerator.CloneObject(original);
1012
}
1113
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#nullable enable
2+
using System;
3+
using System.Collections.Generic;
4+
using System.Collections.Concurrent;
5+
using System.Linq;
6+
using System.Linq.Expressions;
7+
using System.Net.Http;
8+
using System.Reflection;
9+
using System.Runtime.CompilerServices;
10+
using System.Threading;
11+
namespace Foundatio.FastCloner.Code;
12+
13+
internal class AhoCorasick
14+
{
15+
private class Node
16+
{
17+
public readonly Dictionary<char, Node> Children = new Dictionary<char, Node>();
18+
public Node? Failure;
19+
public bool IsEndOfPattern;
20+
}
21+
22+
private readonly Node root = new Node();
23+
private readonly string[] patterns;
24+
25+
public AhoCorasick(string[] patterns)
26+
{
27+
this.patterns = patterns;
28+
BuildTrie();
29+
BuildFailureLinks();
30+
}
31+
32+
private void BuildTrie()
33+
{
34+
foreach (string pattern in patterns)
35+
{
36+
Node current = root;
37+
foreach (char c in pattern)
38+
{
39+
if (!current.Children.TryGetValue(c, out Node value))
40+
{
41+
value = new Node();
42+
current.Children[c] = value;
43+
}
44+
current = value;
45+
}
46+
47+
current.IsEndOfPattern = true;
48+
}
49+
}
50+
51+
private void BuildFailureLinks()
52+
{
53+
Queue<Node> queue = new Queue<Node>();
54+
55+
foreach (Node node in root.Children.Values)
56+
{
57+
node.Failure = root;
58+
queue.Enqueue(node);
59+
}
60+
61+
while (queue.Count > 0)
62+
{
63+
Node current = queue.Dequeue();
64+
65+
foreach (KeyValuePair<char, Node> kvp in current.Children)
66+
{
67+
queue.Enqueue(kvp.Value);
68+
69+
Node? failure = current.Failure;
70+
71+
while (failure != null && !failure.Children.ContainsKey(kvp.Key))
72+
{
73+
failure = failure.Failure;
74+
}
75+
76+
kvp.Value.Failure = failure?.Children.GetValueOrDefault(kvp.Key) ?? root;
77+
}
78+
}
79+
}
80+
81+
public bool ContainsAnyPattern(string text)
82+
{
83+
Node? current = root;
84+
85+
foreach (char c in text)
86+
{
87+
while (current != null && !current.Children.ContainsKey(c))
88+
{
89+
current = current.Failure;
90+
}
91+
92+
current = current?.Children.GetValueOrDefault(c) ?? root;
93+
94+
if (current.IsEndOfPattern)
95+
{
96+
return true;
97+
}
98+
}
99+
return false;
100+
}
101+
}

0 commit comments

Comments
 (0)