From cba436f4dc5c288f3adb72c8daf0c35addf84e6d Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 18 May 2026 20:50:57 +1000 Subject: [PATCH] avoid intermediate Substring in DefaultSerializationBinder generic type-arg parsing --- .../DefaultSerializationBinder.cs | 2 +- src/Argon/Utilities/ReflectionUtils.cs | 23 ++- .../SplitFullyQualifiedTypeNameBench.cs | 144 ++++++++++++++++++ src/Benchmark.Tests/Program.cs | 2 +- 4 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 src/ArgonTests/Benchmarks/SplitFullyQualifiedTypeNameBench.cs diff --git a/src/Argon/Serialization/DefaultSerializationBinder.cs b/src/Argon/Serialization/DefaultSerializationBinder.cs index af63716c9..fcf9f0033 100644 --- a/src/Argon/Serialization/DefaultSerializationBinder.cs +++ b/src/Argon/Serialization/DefaultSerializationBinder.cs @@ -118,7 +118,7 @@ static Type GetTypeFromTypeNameKey(TypeNameKey key) --scope; if (scope == 0) { - var typeArgAssemblyQualifiedName = typeName.Substring(typeArgStartIndex, i - typeArgStartIndex); + var typeArgAssemblyQualifiedName = typeName.AsSpan(typeArgStartIndex, i - typeArgStartIndex); var typeNameKey = ReflectionUtils.SplitFullyQualifiedTypeName(typeArgAssemblyQualifiedName); genericTypeArguments.Add(GetTypeByName(typeNameKey)); diff --git a/src/Argon/Utilities/ReflectionUtils.cs b/src/Argon/Utilities/ReflectionUtils.cs index 385faeea8..4b5d1a4fa 100644 --- a/src/Argon/Utilities/ReflectionUtils.cs +++ b/src/Argon/Utilities/ReflectionUtils.cs @@ -444,7 +444,7 @@ public static List GetFieldsAndProperties(this Type type, BindingFla public static TypeNameKey SplitFullyQualifiedTypeName(string fullTypeName) { - var assemblyDelimiterIndex = GetAssemblyDelimiterIndex(fullTypeName); + var assemblyDelimiterIndex = GetAssemblyDelimiterIndex(fullTypeName.AsSpan()); if (assemblyDelimiterIndex == null) { @@ -452,12 +452,27 @@ public static TypeNameKey SplitFullyQualifiedTypeName(string fullTypeName) } var delimiterIndex = assemblyDelimiterIndex.Value; - var type = fullTypeName.Trim(0, delimiterIndex); - var assembly = fullTypeName.Trim(delimiterIndex + 1, fullTypeName.Length - delimiterIndex - 1); + var type = fullTypeName.AsSpan(0, delimiterIndex).Trim().ToString(); + var assembly = fullTypeName.AsSpan(delimiterIndex + 1).Trim().ToString(); + return new(assembly, type); + } + + public static TypeNameKey SplitFullyQualifiedTypeName(CharSpan fullTypeName) + { + var assemblyDelimiterIndex = GetAssemblyDelimiterIndex(fullTypeName); + + if (assemblyDelimiterIndex == null) + { + return new(null, fullTypeName.ToString()); + } + + var delimiterIndex = assemblyDelimiterIndex.Value; + var type = fullTypeName[..delimiterIndex].Trim().ToString(); + var assembly = fullTypeName[(delimiterIndex + 1)..].Trim().ToString(); return new(assembly, type); } - static int? GetAssemblyDelimiterIndex(string fullyQualifiedTypeName) + static int? GetAssemblyDelimiterIndex(CharSpan fullyQualifiedTypeName) { // we need to get the first comma following all surrounded in brackets because of generic types // e.g. System.Collections.Generic.Dictionary`2[[System.String, mscorlib,Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089],[System.String, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]], mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 diff --git a/src/ArgonTests/Benchmarks/SplitFullyQualifiedTypeNameBench.cs b/src/ArgonTests/Benchmarks/SplitFullyQualifiedTypeNameBench.cs new file mode 100644 index 000000000..f36c3d542 --- /dev/null +++ b/src/ArgonTests/Benchmarks/SplitFullyQualifiedTypeNameBench.cs @@ -0,0 +1,144 @@ +// Copyright (c) 2007 James Newton-King. All rights reserved. +// Use of this source code is governed by The MIT License, +// as found in the license.md file. + +using BenchmarkDotNet.Attributes; + +[MemoryDiagnoser] +public class SplitFullyQualifiedTypeNameBench +{ + // Mirrors the kind of value passed at DefaultSerializationBinder line ~121: + // a generic typename with nested assembly-qualified type-argument blocks. + const string TypeName = + "System.Collections.Generic.Dictionary`2[" + + "[System.String, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]," + + "[System.Collections.Generic.List`1[" + + "[System.Int32, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]" + + "], mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"; + + static readonly (int Start, int Length)[] argSlices = LocateArgSlices(TypeName); + + static (int, int)[] LocateArgSlices(string typeName) + { + var openBracketIndex = typeName.IndexOf('['); + var slices = new List<(int, int)>(); + var scope = 0; + var argStart = 0; + var endIndex = typeName.Length - 1; + for (var i = openBracketIndex + 1; i < endIndex; ++i) + { + switch (typeName[i]) + { + case '[': + if (scope == 0) + { + argStart = i + 1; + } + + ++scope; + break; + case ']': + --scope; + if (scope == 0) + { + slices.Add((argStart, i - argStart)); + } + + break; + } + } + + return slices.ToArray(); + } + + [Benchmark(Baseline = true)] + public int Old_SubstringPlusStringSplit() + { + var sum = 0; + foreach (var (start, length) in argSlices) + { + var arg = TypeName.Substring(start, length); + var key = OldSplitFullyQualifiedTypeName(arg); + sum += key.Type.Length + (key.Assembly?.Length ?? 0); + } + + return sum; + } + + [Benchmark] + public int New_AsSpanPlusSpanSplit() + { + var sum = 0; + foreach (var (start, length) in argSlices) + { + var arg = TypeName.AsSpan(start, length); + var key = ReflectionUtils.SplitFullyQualifiedTypeName(arg); + sum += key.Type.Length + (key.Assembly?.Length ?? 0); + } + + return sum; + } + + static TypeNameKey OldSplitFullyQualifiedTypeName(string fullTypeName) + { + var assemblyDelimiterIndex = OldGetAssemblyDelimiterIndex(fullTypeName); + + if (assemblyDelimiterIndex == null) + { + return new(null, fullTypeName); + } + + var delimiterIndex = assemblyDelimiterIndex.Value; + var type = OldTrim(fullTypeName, 0, delimiterIndex); + var assembly = OldTrim(fullTypeName, delimiterIndex + 1, fullTypeName.Length - delimiterIndex - 1); + return new(assembly, type); + } + + static int? OldGetAssemblyDelimiterIndex(string fullyQualifiedTypeName) + { + var scope = 0; + for (var i = 0; i < fullyQualifiedTypeName.Length; i++) + { + switch (fullyQualifiedTypeName[i]) + { + case '[': + scope++; + break; + case ']': + scope--; + break; + case ',': + if (scope == 0) + { + return i; + } + + break; + } + } + + return null; + } + + static string OldTrim(string s, int start, int length) + { + var end = start + length - 1; + for (; start < end; start++) + { + if (!char.IsWhiteSpace(s[start])) + { + break; + } + } + + for (; end >= start; end--) + { + if (!char.IsWhiteSpace(s[end])) + { + break; + } + } + + return s.Substring(start, end - start + 1); + } +} diff --git a/src/Benchmark.Tests/Program.cs b/src/Benchmark.Tests/Program.cs index 63432b0e9..cd1c07820 100644 --- a/src/Benchmark.Tests/Program.cs +++ b/src/Benchmark.Tests/Program.cs @@ -11,7 +11,7 @@ public static void Main(string[] args) var attribute = (AssemblyFileVersionAttribute)typeof(JsonConvert).Assembly.GetCustomAttribute(typeof(AssemblyFileVersionAttribute))!; Console.WriteLine($"Json.NET Version: {attribute.Version}"); - var switcher = new BenchmarkSwitcher([typeof(WriteEscapedJavaScriptString), typeof(SerializeJTokenList), typeof(ReadQuotedNumbers)]); + var switcher = new BenchmarkSwitcher([typeof(WriteEscapedJavaScriptString), typeof(SerializeJTokenList), typeof(ReadQuotedNumbers), typeof(SplitFullyQualifiedTypeNameBench)]); if (args.Length == 0) { switcher.Run(["*"]);