Skip to content

Commit efce05b

Browse files
committed
runtime: reduce allocations per type, improve locality usage when cloning 2d arrays
1 parent f2e838d commit efce05b

17 files changed

Lines changed: 774 additions & 514 deletions

src/FastCloner.Benchmark/FastCloner.Benchmark.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
<ItemGroup>
1212
<PackageReference Include="AnyClone" Version="1.1.6" />
13-
<PackageReference Include="AutoMapper" Version="16.0.0" />
13+
<PackageReference Include="AutoMapper" Version="16.1.0" />
1414
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
1515
<PackageReference Include="DeepCloner" Version="0.10.4" />
1616
<PackageReference Include="DeepCopier" Version="1.0.4" />
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using BenchmarkDotNet.Attributes;
2+
using BenchmarkDotNet.Order;
3+
4+
namespace FastCloner.Benchmark.Ideas.Clone1DimArrayClass;
5+
6+
[RankColumn]
7+
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
8+
[MemoryDiagnoser]
9+
[InProcess]
10+
[WarmupCount(3)]
11+
[IterationCount(8)]
12+
public class BenchClone1DimArrayClassIdeas : Idea1dArrayClassBenchmarkBase
13+
{
14+
private static readonly Func<object?, IdeaCloneState, object?> CloneTracking = SimulatedCloneTracking;
15+
private static readonly Func<object?, object?> CloneNoTracking = SimulatedCloneNoTracking;
16+
17+
[Benchmark(Baseline = true)]
18+
public int Original()
19+
{
20+
Clone1DimArrayClassIdeaMethods.Original(Source, Target, State, CloneTracking);
21+
return Target[Length - 1]?.Length ?? -1;
22+
}
23+
24+
[Benchmark]
25+
public int NullFastPath()
26+
{
27+
Clone1DimArrayClassIdeaMethods.NullFastPath(Source, Target, State, CloneTracking);
28+
return Target[Length - 1]?.Length ?? -1;
29+
}
30+
31+
[Benchmark]
32+
public int NullFastPathAndNoTracking()
33+
{
34+
Clone1DimArrayClassIdeaMethods.NullFastPathAndNoTracking(Source, Target, State, CloneTracking, CloneNoTracking);
35+
return Target[Length - 1]?.Length ?? -1;
36+
}
37+
38+
private static object? SimulatedCloneTracking(object? obj, IdeaCloneState state)
39+
{
40+
if (obj == null)
41+
return null;
42+
43+
// Keep a tiny bit of non-trivial work so this better resembles real clone paths.
44+
if (state.TrackReferences && obj is string s && s.Length == 0)
45+
return string.Empty;
46+
47+
return obj;
48+
}
49+
50+
private static object? SimulatedCloneNoTracking(object? obj)
51+
{
52+
return obj;
53+
}
54+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using FastCloner.Benchmark.Ideas;
2+
3+
namespace FastCloner.Benchmark.Ideas.Clone1DimArrayClass;
4+
5+
public sealed class Clone1DimArrayClassIdea : IBenchmarkIdea
6+
{
7+
public string Id => "1d-array-class";
8+
public string Description => "Clone1DimArrayClassInternal original vs null/no-tracking variants";
9+
public Type BenchmarkType => typeof(BenchClone1DimArrayClassIdeas);
10+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
namespace FastCloner.Benchmark.Ideas.Clone1DimArrayClass;
2+
3+
public sealed class IdeaCloneState(bool trackReferences)
4+
{
5+
public bool TrackReferences { get; } = trackReferences;
6+
7+
private object? _knownFrom;
8+
private object? _knownTo;
9+
10+
public void AddKnownRef(object from, object to)
11+
{
12+
if (!TrackReferences)
13+
return;
14+
15+
_knownFrom = from;
16+
_knownTo = to;
17+
}
18+
}
19+
20+
internal static class Clone1DimArrayClassIdeaMethods
21+
{
22+
// Lifted from the original method shape:
23+
// always calls cloner with state and does not special-case null elements.
24+
internal static T?[]? Original<T>(
25+
T?[]? objFrom,
26+
T?[]? objTo,
27+
IdeaCloneState state,
28+
Func<object?, IdeaCloneState, object?> cloneTracking)
29+
{
30+
if (objFrom == null || objTo == null) return null;
31+
int l = Math.Min(objFrom.Length, objTo.Length);
32+
state.AddKnownRef(objFrom, objTo);
33+
for (int i = 0; i < l; i++)
34+
objTo[i] = (T?)cloneTracking(objFrom[i], state);
35+
36+
return objTo;
37+
}
38+
39+
// Optimization idea 1: null fast-path.
40+
internal static T?[]? NullFastPath<T>(
41+
T?[]? objFrom,
42+
T?[]? objTo,
43+
IdeaCloneState state,
44+
Func<object?, IdeaCloneState, object?> cloneTracking)
45+
{
46+
if (objFrom == null || objTo == null) return null;
47+
int l = Math.Min(objFrom.Length, objTo.Length);
48+
state.AddKnownRef(objFrom, objTo);
49+
for (int i = 0; i < l; i++)
50+
{
51+
object? item = objFrom[i];
52+
objTo[i] = item == null ? default : (T?)cloneTracking(item, state);
53+
}
54+
55+
return objTo;
56+
}
57+
58+
// Optimization idea 2: avoid tracking-aware cloner calls when tracking is disabled.
59+
internal static T?[]? NullFastPathAndNoTracking<T>(
60+
T?[]? objFrom,
61+
T?[]? objTo,
62+
IdeaCloneState state,
63+
Func<object?, IdeaCloneState, object?> cloneTracking,
64+
Func<object?, object?> cloneNoTracking)
65+
{
66+
if (objFrom == null || objTo == null) return null;
67+
int l = Math.Min(objFrom.Length, objTo.Length);
68+
state.AddKnownRef(objFrom, objTo);
69+
70+
if (state.TrackReferences)
71+
{
72+
for (int i = 0; i < l; i++)
73+
{
74+
object? item = objFrom[i];
75+
objTo[i] = item == null ? default : (T?)cloneTracking(item, state);
76+
}
77+
}
78+
else
79+
{
80+
for (int i = 0; i < l; i++)
81+
{
82+
object? item = objFrom[i];
83+
objTo[i] = item == null ? default : (T?)cloneNoTracking(item);
84+
}
85+
}
86+
87+
return objTo;
88+
}
89+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using BenchmarkDotNet.Attributes;
2+
3+
namespace FastCloner.Benchmark.Ideas.Clone1DimArrayClass;
4+
5+
public abstract class Idea1dArrayClassBenchmarkBase
6+
{
7+
[Params(200_000)]
8+
public int Length { get; set; }
9+
10+
[Params(0, 80)]
11+
public int NullPercent { get; set; }
12+
13+
[Params(true, false)]
14+
public bool TrackReferences { get; set; }
15+
16+
protected string?[] Source = null!;
17+
protected string?[] Target = null!;
18+
protected IdeaCloneState State = null!;
19+
20+
[GlobalSetup]
21+
public void Setup()
22+
{
23+
Source = new string?[Length];
24+
Target = new string?[Length];
25+
State = new IdeaCloneState(TrackReferences);
26+
27+
// Deterministic distribution to keep runs stable.
28+
int step = NullPercent <= 0 ? int.MaxValue : Math.Max(1, 100 / NullPercent);
29+
for (int i = 0; i < Length; i++)
30+
{
31+
bool isNull = NullPercent > 0 && i % step == 0;
32+
Source[i] = isNull ? null : "item_" + i.ToString();
33+
}
34+
}
35+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using BenchmarkDotNet.Attributes;
2+
using BenchmarkDotNet.Order;
3+
4+
namespace FastCloner.Benchmark.Ideas.Clone2DimArray;
5+
6+
[RankColumn]
7+
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
8+
[MemoryDiagnoser]
9+
[InProcess]
10+
[WarmupCount(3)]
11+
[IterationCount(8)]
12+
public class BenchClone2DimArrayIdeas : Idea2dArrayBenchmarkBase
13+
{
14+
[Benchmark(Baseline = true)]
15+
public int Original_Shallow()
16+
{
17+
Clone2DimArrayIdeaMethods.OriginalShallow(Source, Target);
18+
return Target[ToRows - 1, Cols - 1];
19+
}
20+
21+
[Benchmark]
22+
public int Edited_Shallow()
23+
{
24+
Clone2DimArrayIdeaMethods.EditedShallow(Source, Target);
25+
return Target[ToRows - 1, Cols - 1];
26+
}
27+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
using FastCloner.Benchmark.Ideas;
2+
3+
namespace FastCloner.Benchmark.Ideas.Clone2DimArray;
4+
5+
public sealed class Clone2DimArrayIdea : IBenchmarkIdea
6+
{
7+
public string Id => "2d-array";
8+
public string Description => "Clone2DimArrayInternal original vs locality-optimized shallow copy";
9+
public Type BenchmarkType => typeof(BenchClone2DimArrayIdeas);
10+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
namespace FastCloner.Benchmark.Ideas.Clone2DimArray;
2+
3+
internal static class Clone2DimArrayIdeaMethods
4+
{
5+
// Lifted from the original Clone2DimArrayInternal shallow-copy flow.
6+
internal static T[,] OriginalShallow<T>(T[,] objFrom, T[,] objTo)
7+
{
8+
if (objFrom.GetLowerBound(0) != 0 || objFrom.GetLowerBound(1) != 0
9+
|| objTo.GetLowerBound(0) != 0 || objTo.GetLowerBound(1) != 0)
10+
return (T[,])CloneAbstractArrayShallow(objFrom, objTo)!;
11+
12+
int l1 = Math.Min(objFrom.GetLength(0), objTo.GetLength(0));
13+
int l2 = Math.Min(objFrom.GetLength(1), objTo.GetLength(1));
14+
15+
if (objFrom.GetLength(0) == objTo.GetLength(0)
16+
&& objFrom.GetLength(1) == objTo.GetLength(1))
17+
{
18+
Array.Copy(objFrom, objTo, objFrom.Length);
19+
return objTo;
20+
}
21+
22+
for (int i = 0; i < l1; i++)
23+
for (int k = 0; k < l2; k++)
24+
objTo[i, k] = objFrom[i, k];
25+
26+
return objTo;
27+
}
28+
29+
// Lifted from the edited Clone2DimArrayInternal shallow-copy flow.
30+
internal static T[,] EditedShallow<T>(T[,] objFrom, T[,] objTo)
31+
{
32+
int fromLower0 = objFrom.GetLowerBound(0);
33+
int fromLower1 = objFrom.GetLowerBound(1);
34+
int toLower0 = objTo.GetLowerBound(0);
35+
int toLower1 = objTo.GetLowerBound(1);
36+
if (fromLower0 != 0 || fromLower1 != 0 || toLower0 != 0 || toLower1 != 0)
37+
return (T[,])CloneAbstractArrayShallow(objFrom, objTo)!;
38+
39+
int fromLength0 = objFrom.GetLength(0);
40+
int fromLength1 = objFrom.GetLength(1);
41+
int toLength0 = objTo.GetLength(0);
42+
int toLength1 = objTo.GetLength(1);
43+
44+
int l1 = Math.Min(fromLength0, toLength0);
45+
46+
// Row-major locality optimization: when row width matches, copy contiguous
47+
// row prefix in one block.
48+
if (fromLength1 == toLength1)
49+
{
50+
Array.Copy(objFrom, objTo, l1 * fromLength1);
51+
return objTo;
52+
}
53+
54+
int l2 = Math.Min(fromLength1, toLength1);
55+
for (int i = 0; i < l1; i++)
56+
for (int k = 0; k < l2; k++)
57+
objTo[i, k] = objFrom[i, k];
58+
59+
return objTo;
60+
}
61+
62+
private static Array? CloneAbstractArrayShallow(Array? objFrom, Array? objTo)
63+
{
64+
if (objFrom == null || objTo == null) return null;
65+
int rank = objFrom.Rank;
66+
67+
int[] lowerBoundsFrom = new int[rank];
68+
int[] lowerBoundsTo = new int[rank];
69+
int[] lengths = new int[rank];
70+
int[] idxesFrom = new int[rank];
71+
int[] idxesTo = new int[rank];
72+
bool hasZeroLength = false;
73+
for (int i = 0; i < rank; i++)
74+
{
75+
int lowerBoundFrom = objFrom.GetLowerBound(i);
76+
int lowerBoundTo = objTo.GetLowerBound(i);
77+
int length = Math.Min(objFrom.GetLength(i), objTo.GetLength(i));
78+
79+
lowerBoundsFrom[i] = lowerBoundFrom;
80+
lowerBoundsTo[i] = lowerBoundTo;
81+
lengths[i] = length;
82+
idxesFrom[i] = lowerBoundFrom;
83+
idxesTo[i] = lowerBoundTo;
84+
hasZeroLength |= length == 0;
85+
}
86+
87+
if (hasZeroLength)
88+
return objTo;
89+
90+
while (true)
91+
{
92+
objTo.SetValue(objFrom.GetValue(idxesFrom), idxesTo);
93+
int ofs = rank - 1;
94+
while (true)
95+
{
96+
idxesFrom[ofs]++;
97+
idxesTo[ofs]++;
98+
if (idxesFrom[ofs] >= lowerBoundsFrom[ofs] + lengths[ofs])
99+
{
100+
idxesFrom[ofs] = lowerBoundsFrom[ofs];
101+
idxesTo[ofs] = lowerBoundsTo[ofs];
102+
ofs--;
103+
if (ofs < 0) return objTo;
104+
}
105+
else
106+
break;
107+
}
108+
}
109+
}
110+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using BenchmarkDotNet.Attributes;
2+
3+
namespace FastCloner.Benchmark.Ideas.Clone2DimArray;
4+
5+
public abstract class Idea2dArrayBenchmarkBase
6+
{
7+
// Keep one realistic baseline case that stresses row-prefix copies:
8+
// same column count, different row count.
9+
[Params(2048)]
10+
public int FromRows { get; set; }
11+
12+
[Params(1536)]
13+
public int ToRows { get; set; }
14+
15+
[Params(256)]
16+
public int Cols { get; set; }
17+
18+
protected int[,] Source = null!;
19+
protected int[,] Target = null!;
20+
21+
[GlobalSetup]
22+
public void Setup()
23+
{
24+
if (FromRows <= 0 || ToRows <= 0 || Cols <= 0)
25+
throw new InvalidOperationException("Benchmark dimensions must be positive.");
26+
27+
Source = new int[FromRows, Cols];
28+
Target = new int[ToRows, Cols];
29+
30+
for (int i = 0; i < FromRows; i++)
31+
{
32+
for (int j = 0; j < Cols; j++)
33+
{
34+
Source[i, j] = (i * 31) ^ j;
35+
}
36+
}
37+
}
38+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace FastCloner.Benchmark.Ideas;
2+
3+
public interface IBenchmarkIdea
4+
{
5+
string Id { get; }
6+
string Description { get; }
7+
Type BenchmarkType { get; }
8+
}

0 commit comments

Comments
 (0)