From 7a2db04cfe0aff4f730798075036e8eb69762d1d Mon Sep 17 00:00:00 2001 From: clodan Date: Thu, 20 Feb 2025 15:21:13 +0000 Subject: [PATCH] Added fast cloner and benchmarks --- Benchmarks/Benchmarks.csproj | 24 +++ Benchmarks/ClonerBenchmarks.cs | 50 +++++ Benchmarks/Mock/MockLogger.cs | 68 +++++++ Benchmarks/Program.cs | 12 ++ Libraries/Core/Core.csproj | 1 + Libraries/Core/Utils/Cloners/FastCloner.cs | 12 ++ Libraries/Core/Utils/Cloners/JsonCloner.cs | 6 +- .../Core/Utils/Cloners/ReflectionsCloner.cs | 174 ++++++++++++++++++ Libraries/Core/Utils/Json/ListOrT.cs | 12 ++ UnitTests/Tests/Utils/ClonerTest.cs | 40 ++++ UnitTests/UnitTests.csproj | 1 + server-csharp.sln | 6 + 12 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 Benchmarks/Benchmarks.csproj create mode 100644 Benchmarks/ClonerBenchmarks.cs create mode 100644 Benchmarks/Mock/MockLogger.cs create mode 100644 Benchmarks/Program.cs create mode 100644 Libraries/Core/Utils/Cloners/FastCloner.cs create mode 100644 Libraries/Core/Utils/Cloners/ReflectionsCloner.cs create mode 100644 UnitTests/Tests/Utils/ClonerTest.cs diff --git a/Benchmarks/Benchmarks.csproj b/Benchmarks/Benchmarks.csproj new file mode 100644 index 00000000..def07bbe --- /dev/null +++ b/Benchmarks/Benchmarks.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/Benchmarks/ClonerBenchmarks.cs b/Benchmarks/ClonerBenchmarks.cs new file mode 100644 index 00000000..18925355 --- /dev/null +++ b/Benchmarks/ClonerBenchmarks.cs @@ -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(), new FileUtil(new MockLogger()), + jsonUtil); + var loadTask = importer.LoadRecursiveAsync("./Assets/database/templates/"); + loadTask.Wait(); + _templates = loadTask.Result; + _jsonCloner = new JsonCloner(jsonUtil); + _reflectionsCloner = new ReflectionsCloner(new MockLogger()); + _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); + } +} diff --git a/Benchmarks/Mock/MockLogger.cs b/Benchmarks/Mock/MockLogger.cs new file mode 100644 index 00000000..9e363fb3 --- /dev/null +++ b/Benchmarks/Mock/MockLogger.cs @@ -0,0 +1,68 @@ +using Core.Models.Logging; +using Core.Models.Spt.Logging; +using Core.Models.Utils; + +namespace Benchmarks.Mock; + +public class MockLogger : ISptLogger +{ + 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); + } +} diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs new file mode 100644 index 00000000..f1bfcc65 --- /dev/null +++ b/Benchmarks/Program.cs @@ -0,0 +1,12 @@ +using BenchmarkDotNet.Running; + +namespace Benchmarks; + +public class Program +{ + public static void Main(string[] args) + { + var summary = BenchmarkRunner.Run(); + Console.WriteLine(summary); + } +} diff --git a/Libraries/Core/Core.csproj b/Libraries/Core/Core.csproj index 1462d2b5..462dddde 100644 --- a/Libraries/Core/Core.csproj +++ b/Libraries/Core/Core.csproj @@ -13,6 +13,7 @@ + diff --git a/Libraries/Core/Utils/Cloners/FastCloner.cs b/Libraries/Core/Utils/Cloners/FastCloner.cs new file mode 100644 index 00000000..2d1095d2 --- /dev/null +++ b/Libraries/Core/Utils/Cloners/FastCloner.cs @@ -0,0 +1,12 @@ +using SptCommon.Annotations; + +namespace Core.Utils.Cloners; + +[Injectable] +public class FastCloner : ICloner +{ + public T? Clone(T? obj) + { + return global::FastCloner.FastCloner.DeepClone(obj); + } +} diff --git a/Libraries/Core/Utils/Cloners/JsonCloner.cs b/Libraries/Core/Utils/Cloners/JsonCloner.cs index b571554c..374c838f 100644 --- a/Libraries/Core/Utils/Cloners/JsonCloner.cs +++ b/Libraries/Core/Utils/Cloners/JsonCloner.cs @@ -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; diff --git a/Libraries/Core/Utils/Cloners/ReflectionsCloner.cs b/Libraries/Core/Utils/Cloners/ReflectionsCloner.cs new file mode 100644 index 00000000..e705bdb5 --- /dev/null +++ b/Libraries/Core/Utils/Cloners/ReflectionsCloner.cs @@ -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 logger) : ICloner +{ + private static Dictionary MemberInfoCache = new(); + private static Dictionary AddMethodInfoCache = new(); + + public T? Clone(T? obj) + { + return (T?) Clone(obj, typeof(T)).Result; + } + + public async Task 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 _itemPropertyInfoCache = new(); + private static ConcurrentDictionary _listPropertyInfoCache = new(); + + private async Task 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; + } +} diff --git a/Libraries/Core/Utils/Json/ListOrT.cs b/Libraries/Core/Utils/Json/ListOrT.cs index 40697865..74d90a6b 100644 --- a/Libraries/Core/Utils/Json/ListOrT.cs +++ b/Libraries/Core/Utils/Json/ListOrT.cs @@ -2,14 +2,26 @@ namespace Core.Utils.Json; public class ListOrT(List? list, T? item) { + // Do not remove, its used by the cloner + // ReSharper disable once UnusedMember.Local + private ListOrT() : this(null, default) + { + } + public List? 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 diff --git a/UnitTests/Tests/Utils/ClonerTest.cs b/UnitTests/Tests/Utils/ClonerTest.cs new file mode 100644 index 00000000..6475c38e --- /dev/null +++ b/UnitTests/Tests/Utils/ClonerTest.cs @@ -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(), new FileUtil(new MockLogger()), _jsonUtil); + var loadTask = importer.LoadRecursiveAsync("./TestAssets/"); + loadTask.Wait(); + _templates = loadTask.Result; + _jsonCloner = new JsonCloner(_jsonUtil); + _reflectionsCloner = new ReflectionsCloner(new MockLogger()); + _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 + } +} diff --git a/UnitTests/UnitTests.csproj b/UnitTests/UnitTests.csproj index a67a8542..98c21143 100644 --- a/UnitTests/UnitTests.csproj +++ b/UnitTests/UnitTests.csproj @@ -7,6 +7,7 @@ + diff --git a/server-csharp.sln b/server-csharp.sln index eb6f0ff8..8936edb4 100644 --- a/server-csharp.sln +++ b/server-csharp.sln @@ -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