Added fast cloner and benchmarks

This commit is contained in:
clodan
2025-02-20 15:21:13 +00:00
parent 5a5e18f568
commit 7a2db04cfe
12 changed files with 403 additions and 3 deletions
+24
View File
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0"/>
<ProjectReference Include="..\Libraries\Core\Core.csproj"/>
<ProjectReference Include="..\Libraries\SptAssets\SptAssets.csproj"/>
<ProjectReference Include="..\Libraries\SptCommon\SptCommon.csproj"/>
<ProjectReference Include="..\Libraries\SptDependencyInjection\SptDependencyInjection.csproj"/>
</ItemGroup>
<ItemGroup>
<Content Include="Assets\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+50
View File
@@ -0,0 +1,50 @@
using BenchmarkDotNet.Attributes;
using Benchmarks.Mock;
using Core.Models.Spt.Templates;
using Core.Utils;
using Core.Utils.Cloners;
namespace Benchmarks;
[SimpleJob(warmupCount: 10, iterationCount: 10)]
[MemoryDiagnoser]
public class ClonerBenchmarks
{
private Templates? _templates;
private ICloner _jsonCloner;
private ICloner _reflectionsCloner;
private ICloner _fastCloner;
[GlobalSetup]
public void Setup()
{
var jsonUtil = new JsonUtil();
var importer = new ImporterUtil(new MockLogger<ImporterUtil>(), new FileUtil(new MockLogger<FileUtil>()),
jsonUtil);
var loadTask = importer.LoadRecursiveAsync<Templates>("./Assets/database/templates/");
loadTask.Wait();
_templates = loadTask.Result;
_jsonCloner = new JsonCloner(jsonUtil);
_reflectionsCloner = new ReflectionsCloner(new MockLogger<ReflectionsCloner>());
_fastCloner = new Core.Utils.Cloners.FastCloner();
}
[Benchmark]
public void JsonCloner()
{
_jsonCloner.Clone(_templates);
}
[Benchmark]
public void ReflectionsCloner()
{
_reflectionsCloner.Clone(_templates);
}
[Benchmark(Baseline = true)]
public void FastCloner()
{
_fastCloner.Clone(_templates);
}
}
+68
View File
@@ -0,0 +1,68 @@
using Core.Models.Logging;
using Core.Models.Spt.Logging;
using Core.Models.Utils;
namespace Benchmarks.Mock;
public class MockLogger<T> : ISptLogger<T>
{
public void LogWithColor(string data, LogTextColor? textColor = null, LogBackgroundColor? backgroundColor = null, Exception? ex = null)
{
throw new NotImplementedException();
}
public void Success(string data, Exception? ex = null)
{
Console.WriteLine(data);
}
public void Error(string data, Exception? ex = null)
{
Console.WriteLine(data);
}
public void Warning(string data, Exception? ex = null)
{
Console.WriteLine(data);
}
public void Info(string data, Exception? ex = null)
{
Console.WriteLine(data);
}
public void Debug(string data, Exception? ex = null)
{
Console.WriteLine(data);
}
public void Critical(string data, Exception? ex = null)
{
Console.WriteLine(data);
}
public void WriteToLogFile(string body, LogLevel level = LogLevel.Info)
{
throw new NotImplementedException();
}
public bool IsLogEnabled(LogLevel level)
{
return false;
}
public void LogWithColor(
string data,
Exception? ex = null,
LogTextColor? textColor = null,
LogBackgroundColor? backgroundColor = null
)
{
Console.WriteLine(data);
}
public void WriteToLogFile(object body)
{
Console.WriteLine(body);
}
}
+12
View File
@@ -0,0 +1,12 @@
using BenchmarkDotNet.Running;
namespace Benchmarks;
public class Program
{
public static void Main(string[] args)
{
var summary = BenchmarkRunner.Run<ClonerBenchmarks>();
Console.WriteLine(summary);
}
}
+1
View File
@@ -13,6 +13,7 @@
<ItemGroup>
<PackageReference Include="System.IO.Hashing" Version="9.0.1" />
<PackageReference Include="FastCloner" Version="3.3.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
using SptCommon.Annotations;
namespace Core.Utils.Cloners;
[Injectable]
public class FastCloner : ICloner
{
public T? Clone<T>(T? obj)
{
return global::FastCloner.FastCloner.DeepClone(obj);
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
using SptCommon.Annotations;
namespace Core.Utils.Cloners;
[Injectable]
/**
* Disabled as FastCloner library is 15% faster and consumes less memory than Json serialization
*/
public class JsonCloner : ICloner
{
protected JsonUtil _jsonUtil;
@@ -0,0 +1,174 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Reflection;
using Core.Models.Utils;
using Core.Utils.Json;
using LogLevel = Core.Models.Spt.Logging.LogLevel;
namespace Core.Utils.Cloners;
/**
* Not in use at the moment
*/
public class ReflectionsCloner(ISptLogger<ReflectionsCloner> logger) : ICloner
{
private static Dictionary<Type, MemberInfo[]> MemberInfoCache = new();
private static Dictionary<Type, MethodInfo> AddMethodInfoCache = new();
public T? Clone<T>(T? obj)
{
return (T?) Clone(obj, typeof(T)).Result;
}
public async Task<object?> Clone(object? obj, Type objectType)
{
// if its null, primitive, enum or string, just return the object
if (obj == null)
{
return obj;
}
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(Nullable<>))
{
objectType = Nullable.GetUnderlyingType(objectType);
}
if (objectType.IsPrimitive || objectType.IsEnum || obj is string)
{
return obj;
}
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(ListOrT<>))
{
return await HandleSpecialClones(obj, objectType);
}
var result = Activator.CreateInstance(objectType);
if (obj is IList listToClone)
{
foreach (var toClone in listToClone)
{
var cloned = await Clone(toClone, toClone.GetType());
(result as IList).Add(cloned);
}
}
else if (obj is IDictionary dictionaryToClone)
{
foreach (DictionaryEntry entryToClone in dictionaryToClone)
{
var clonedKey = await Clone(entryToClone.Key, entryToClone.Key.GetType());
var clonedValue = await Clone(entryToClone.Value, entryToClone.Value.GetType());
(result as IDictionary).Add(clonedKey, clonedValue);
}
}
else if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(HashSet<>))
{
if (!AddMethodInfoCache.TryGetValue(objectType, out var addMethodInfo))
{
addMethodInfo = objectType.GetMethod("Add", BindingFlags.Instance | BindingFlags.Public);
while (!AddMethodInfoCache.TryAdd(objectType, addMethodInfo)) ;
}
var toCloneEnumerable = (IEnumerable) obj;
foreach (var toClone in toCloneEnumerable)
{
try
{
var cloned = await Clone(toClone, toClone.GetType());
addMethodInfo.Invoke(result, [cloned]);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}
else if (objectType.IsClass)
{
if (!MemberInfoCache.TryGetValue(objectType, out var memberInfos))
{
memberInfos = objectType.GetMembers(BindingFlags.Public | BindingFlags.Instance);
while (!MemberInfoCache.TryAdd(objectType, memberInfos)) ;
}
foreach (var member in memberInfos)
{
try
{
switch (member)
{
case PropertyInfo propertyInfo:
var propertyValue = propertyInfo.GetValue(obj, null);
var propertyCloned = await Clone(propertyValue, propertyInfo.PropertyType);
propertyInfo.SetValue(result, propertyCloned, null);
break;
case FieldInfo fieldInfo:
var fieldValue = fieldInfo.GetValue(obj);
var fieldCloned = await Clone(fieldValue, fieldInfo.FieldType);
fieldInfo.SetValue(result, fieldCloned);
break;
case MemberInfo:
break;
default:
if (logger.IsLogEnabled(LogLevel.Debug))
{
logger.Debug($"Unknown member type {member.Name} {member.MemberType}");
}
break;
}
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
}
else
{
if (logger.IsLogEnabled(LogLevel.Debug))
logger.Debug($"Clone of type {objectType} is not supported");
}
return result;
}
private static ConcurrentDictionary<Type, PropertyInfo> _itemPropertyInfoCache = new();
private static ConcurrentDictionary<Type, PropertyInfo> _listPropertyInfoCache = new();
private async Task<object?> HandleSpecialClones(object obj, Type objectType)
{
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(ListOrT<>))
{
var clone = Activator.CreateInstance(objectType, true);
var type = objectType.GetGenericArguments()[0];
if (!_itemPropertyInfoCache.TryGetValue(type, out var item))
{
item = objectType.GetProperty("Item", BindingFlags.Public | BindingFlags.Instance);
while (!_itemPropertyInfoCache.TryAdd(type, item)) ;
}
if (!_listPropertyInfoCache.TryGetValue(type, out var list))
{
list = objectType.GetProperty("List", BindingFlags.Public | BindingFlags.Instance);
while (!_listPropertyInfoCache.TryAdd(type, list)) ;
}
item.GetSetMethod(true).Invoke(clone, [await Clone(item.GetValue(obj), item.PropertyType)]);
list.GetSetMethod(true).Invoke(clone, [await Clone(list.GetValue(obj), list.PropertyType)]);
return clone;
}
return null;
}
}
+12
View File
@@ -2,14 +2,26 @@ namespace Core.Utils.Json;
public class ListOrT<T>(List<T>? list, T? item)
{
// Do not remove, its used by the cloner
// ReSharper disable once UnusedMember.Local
private ListOrT() : this(null, default)
{
}
public List<T>? List
{
get;
// Do not remove, its used by the cloner
// ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local
private set;
} = list;
public T? Item
{
get;
// do not remove, its used by the cloner
// ReSharper disable once AutoPropertyCanBeMadeGetOnly.Local
private set;
} = item;
public bool IsItem
+40
View File
@@ -0,0 +1,40 @@
using Core.Models.Spt.Templates;
using Core.Utils;
using Core.Utils.Cloners;
using UnitTests.Mock;
namespace UnitTests.Tests.Utils;
[TestClass]
public class ClonerTest
{
private Templates? _templates;
private ICloner _jsonCloner;
private ICloner _reflectionsCloner;
private ICloner _fastCloner;
private JsonUtil _jsonUtil;
[TestInitialize]
public void Setup()
{
_jsonUtil = new JsonUtil();
var importer = new ImporterUtil(new MockLogger<ImporterUtil>(), new FileUtil(new MockLogger<FileUtil>()), _jsonUtil);
var loadTask = importer.LoadRecursiveAsync<Templates>("./TestAssets/");
loadTask.Wait();
_templates = loadTask.Result;
_jsonCloner = new JsonCloner(_jsonUtil);
_reflectionsCloner = new ReflectionsCloner(new MockLogger<ReflectionsCloner>());
_fastCloner = new Core.Utils.Cloners.FastCloner();
}
[TestMethod]
public void FastCloner()
{
var jsonObject = _jsonCloner.Clone(_templates);
var reflectionObject = _reflectionsCloner.Clone(_templates);
var fastObject = _fastCloner.Clone(_templates);
// This test is just used for cloner comparison, not a real test
}
}
+1
View File
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FastCloner" Version="3.3.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1"/>
<PackageReference Include="MSTest" Version="3.6.1"/>
</ItemGroup>
+6
View File
@@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SptAssets", "Libraries\SptA
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HideoutCraftQuestIdGenerator", "Tools\HideoutCraftQuestIdGenerator\HideoutCraftQuestIdGenerator.csproj", "{C24B1FEB-F8AC-434E-998D-5DA4D1687295}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Benchmarks", "Benchmarks\Benchmarks.csproj", "{6884273A-72E9-4035-B5BE-EE101C69F5F5}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -61,6 +63,10 @@ Global
{C24B1FEB-F8AC-434E-998D-5DA4D1687295}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C24B1FEB-F8AC-434E-998D-5DA4D1687295}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C24B1FEB-F8AC-434E-998D-5DA4D1687295}.Release|Any CPU.Build.0 = Release|Any CPU
{6884273A-72E9-4035-B5BE-EE101C69F5F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6884273A-72E9-4035-B5BE-EE101C69F5F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6884273A-72E9-4035-B5BE-EE101C69F5F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6884273A-72E9-4035-B5BE-EE101C69F5F5}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE