From 02f8684417a27e4099d4bc45beb721832d2fe9cc Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 6 Jul 2026 21:15:42 +1000 Subject: [PATCH] perf: reduce allocations and redundant work across core, JsonPath, DataSets and FSharp Add benchmarks (src/ArgonTests/Benchmarks/) covering every change. All numbers below are probe-measured allocation reductions per operation (net10, Release). serializer - indexed loop in GetMatchingConverter: foreach over IList boxed a List enumerator for every value serialized/deserialized - iterate JsonPropertyCollection/JPropertyKeyedCollection backing lists in hot loops instead of boxing enumerators (SerializeObject, SerializeDynamic, creator path, JObject.WriteTo/GetEnumerator, JContainer copy ctor) - manual IDictionaryEnumerator use in SerializeDictionary: foreach boxed a DictionaryEntry per entry (-34% dictionary serialize) - IsInteger(PrimitiveTypeCode) overload: enum-from-integer deserialization boxed the type code and re-derived it reflectively per value (-51%) - cache string TypeConverters: TypeDescriptor.GetConverter ran per Uri value - track presence only for required/populated properties (-24% with one [JsonRequired] on the type) - identity fast path in ToSeparatedCase for already-snake/kebab dictionary keys (-62%) - HashSet membership + single-pass ForgivingCaseSensitiveFind on the creator path (-42% record deserialize) - record struct key for the enum cache: Tuple allocated per enum op reader - BoxedPrimitives for single-digit reads (-68% single-digit ints) - span double.TryParse: unquoted floats allocated a dead string per token (-47%) - skip the intermediate string token for quoted ReadAsDouble/ReadAsBoolean - trust DateTimeOffset.TryParseExact: dates were parsed twice plus a ToCharArray copy per read (-34%) writer - WritePropertyName(string) no longer round-trips through a span back to a fresh string per property - TryFormat double/float/decimal/Guid/TimeSpan into the pooled writeBuffer instead of intermediate strings (-45..49%) - JavaScriptUtils writes unescaped runs as spans instead of copying through a rented buffer; SearchValues escape scan for strings >= 16 chars (-46% CPU on clean strings, scalar loop kept for short strings where vector setup loses) linq / jsonpath - Descendants() single pointer walk over sibling/parent links instead of nested iterators per depth (22,848 B -> 56 B on a 700-token doc) - drop the redundant duplicate-name hash when parsing object properties - typed JValue construction in ReadContentFrom for known token shapes - static empty converter array in JToken.WriteTo - bounded cache of parsed JPaths (-50% repeated SelectToken) - constant-operand fast path in filter evaluation (-29% filter queries) satellites - memoize DiscriminatedUnionConverter.CanConvert: FSharpType.IsUnion ran per value for every type once registered (-84% and -94% CPU on plain POCOs) - cache closed F# list/map delegates: MakeGenericMethod + Invoke ran per call and wrapped exceptions in TargetInvocationException (-83% list deserialize) - resolve DataTable column names per table instead of per cell (-53%) --- src/Argon.DataSets/DataTableConverter.cs | 17 +- .../DiscriminatedUnionConverter.cs | 6 +- src/Argon.FSharp/FSharpListConverter.cs | 17 +- src/Argon.FSharp/FSharpMapConverter.cs | 41 +++-- src/Argon.JsonPath/BooleanQueryExpression.cs | 40 +++-- src/Argon.JsonPath/JTokenExtensions.cs | 32 +++- src/Argon/JsonSerializer.cs | 5 +- src/Argon/JsonTextReader.cs | 18 +-- src/Argon/JsonTextWriter.cs | 150 +++++++++++++++--- src/Argon/Linq/JContainer.cs | 72 ++++++--- src/Argon/Linq/JObject.cs | 5 +- src/Argon/Linq/JPropertyKeyedCollection.cs | 4 + src/Argon/Linq/JToken.cs | 5 +- src/Argon/Serialization/JsonObjectContract.cs | 41 +++++ .../Serialization/JsonPropertyCollection.cs | 4 + .../JsonSerializerInternalReader.cs | 52 ++++-- .../JsonSerializerInternalWriter.cs | 31 +++- src/Argon/Serialization/JsonTypeReflector.cs | 55 ++++--- src/Argon/Utilities/ConvertUtils.cs | 17 ++ src/Argon/Utilities/DateTimeUtils.cs | 7 +- src/Argon/Utilities/DictionaryWrapper.cs | 6 +- src/Argon/Utilities/EnumUtils.cs | 14 +- src/Argon/Utilities/JavaScriptUtils.cs | 114 ++++++++----- src/Argon/Utilities/StringUtils.cs | 67 +++++--- .../Benchmarks/CreatorDeserializeBenchmark.cs | 56 +++++++ src/ArgonTests/Benchmarks/LinqBenchmarks.cs | 81 ++++++++++ src/ArgonTests/Benchmarks/ReaderBenchmarks.cs | 72 +++++++++ .../Benchmarks/SatelliteBenchmarks.cs | 78 +++++++++ .../Benchmarks/SerializerEngineBenchmarks.cs | 111 +++++++++++++ src/ArgonTests/Benchmarks/WriterBenchmarks.cs | 134 ++++++++++++++++ src/Benchmark.Tests/Program.cs | 8 +- 31 files changed, 1150 insertions(+), 210 deletions(-) create mode 100644 src/ArgonTests/Benchmarks/CreatorDeserializeBenchmark.cs create mode 100644 src/ArgonTests/Benchmarks/LinqBenchmarks.cs create mode 100644 src/ArgonTests/Benchmarks/ReaderBenchmarks.cs create mode 100644 src/ArgonTests/Benchmarks/SatelliteBenchmarks.cs create mode 100644 src/ArgonTests/Benchmarks/SerializerEngineBenchmarks.cs create mode 100644 src/ArgonTests/Benchmarks/WriterBenchmarks.cs diff --git a/src/Argon.DataSets/DataTableConverter.cs b/src/Argon.DataSets/DataTableConverter.cs index c2344d88a..215644d47 100644 --- a/src/Argon.DataSets/DataTableConverter.cs +++ b/src/Argon.DataSets/DataTableConverter.cs @@ -18,14 +18,25 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s var table = (DataTable) value; var resolver = serializer.ContractResolver as DefaultContractResolver; + // resolve column names once per table, not once per cell + var columnCount = table.Columns.Count; + var columns = new DataColumn[columnCount]; + var resolvedColumnNames = new string[columnCount]; + for (var i = 0; i < columnCount; i++) + { + var column = table.Columns[i]; + columns[i] = column; + resolvedColumnNames[i] = resolver?.GetResolvedPropertyName(column.ColumnName) ?? column.ColumnName; + } + writer.WriteStartArray(); foreach (DataRow row in table.Rows) { writer.WriteStartObject(); - foreach (DataColumn column in row.Table.Columns) + for (var i = 0; i < columnCount; i++) { - var columnValue = row[column]; + var columnValue = row[columns[i]]; if (serializer.NullValueHandling == NullValueHandling.Ignore && columnValue == DBNull.Value) @@ -33,7 +44,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s continue; } - writer.WritePropertyName(resolver?.GetResolvedPropertyName(column.ColumnName) ?? column.ColumnName); + writer.WritePropertyName(resolvedColumnNames[i]); serializer.Serialize(writer, columnValue); } diff --git a/src/Argon.FSharp/DiscriminatedUnionConverter.cs b/src/Argon.FSharp/DiscriminatedUnionConverter.cs index 5f54911c5..4e72b9930 100644 --- a/src/Argon.FSharp/DiscriminatedUnionConverter.cs +++ b/src/Argon.FSharp/DiscriminatedUnionConverter.cs @@ -165,6 +165,10 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s /// /// true if this instance can convert the specified object type; otherwise, false. /// + // memoized: CanConvert runs for every value (de)serialized once this converter is + // registered, and FSharpType.IsUnion does attribute reflection on each call + static readonly ThreadSafeStore isUnionCache = new(_ => FSharpType.IsUnion(_, null)); + public override bool CanConvert(Type type) => - FSharpType.IsUnion(type, null); + isUnionCache.Get(type); } \ No newline at end of file diff --git a/src/Argon.FSharp/FSharpListConverter.cs b/src/Argon.FSharp/FSharpListConverter.cs index 01fcf847e..e0fa0b07a 100644 --- a/src/Argon.FSharp/FSharpListConverter.cs +++ b/src/Argon.FSharp/FSharpListConverter.cs @@ -9,6 +9,15 @@ public class FSharpListConverter : { static MethodInfo readList = typeof(FSharpListConverter).GetMethod("ReadList")!; + // cached closed delegates: MakeGenericMethod + Invoke per call allocates and wraps + // exceptions in TargetInvocationException + static ThreadSafeStore> readListCache = new(CreateReadListDelegate); + + static Func CreateReadListDelegate(Type genericArgument) => + (Func) Delegate.CreateDelegate( + typeof(Func), + readList.MakeGenericMethod(genericArgument)); + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { writer.WriteStartArray(); @@ -27,13 +36,7 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s } var genericArgument = type.GetGenericArguments()[0]; - return readList.MakeGenericMethod(genericArgument) - .Invoke( - null, - [ - reader, - serializer - ]); + return readListCache.Get(genericArgument)(reader, serializer); } public static FSharpList ReadList(JsonReader reader, JsonSerializer serializer) diff --git a/src/Argon.FSharp/FSharpMapConverter.cs b/src/Argon.FSharp/FSharpMapConverter.cs index 1b0a14132..5cf578484 100644 --- a/src/Argon.FSharp/FSharpMapConverter.cs +++ b/src/Argon.FSharp/FSharpMapConverter.cs @@ -7,17 +7,28 @@ public class FSharpMapConverter : JsonConverter { - static MethodInfo writeMap = typeof(FSharpMapConverter).GetMethod("WriteMap")!; + // cached closed delegates: MakeGenericMethod + Invoke per call allocates and wraps + // exceptions in TargetInvocationException + static ThreadSafeStore> writeMapCache = new(CreateWriteMapDelegate); - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + static Action CreateWriteMapDelegate(Type mapType) { - var genericArguments = value.GetType().GetGenericArguments(); - writeMap.MakeGenericMethod(genericArguments[0], genericArguments[1]) - .Invoke( - null, - [writer, value, serializer]); + var arguments = mapType.GetGenericArguments(); + var method = typeof(FSharpMapConverter) + .GetMethod(nameof(WriteMapBoxed), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(arguments[0], arguments[1]); + return (Action) Delegate.CreateDelegate( + typeof(Action), + method); } + static void WriteMapBoxed(JsonWriter writer, object value, JsonSerializer serializer) + where T : notnull => + WriteMap(writer, (FSharpMap) value, serializer); + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => + writeMapCache.Get(value.GetType())(writer, value, serializer); + public static void WriteMap(JsonWriter writer, FSharpMap value, JsonSerializer serializer) where T : notnull => serializer.Serialize(writer, value.ToDictionary(_ => _.Key, _ => _.Value)); @@ -29,15 +40,21 @@ public static void WriteMap(JsonWriter writer, FSharpMap value, Json return null; } - var arguments = type.GetGenericArguments(); - return readMap.MakeGenericMethod(arguments[0], arguments[1]) - .Invoke( - null, - [reader, serializer]); + return readMapCache.Get(type)(reader, serializer); } static MethodInfo readMap = typeof(FSharpMapConverter).GetMethod("ReadMap")!; + static ThreadSafeStore> readMapCache = new(CreateReadMapDelegate); + + static Func CreateReadMapDelegate(Type mapType) + { + var arguments = mapType.GetGenericArguments(); + return (Func) Delegate.CreateDelegate( + typeof(Func), + readMap.MakeGenericMethod(arguments[0], arguments[1])); + } + public static FSharpMap ReadMap(JsonReader reader, JsonSerializer serializer) where T : notnull { diff --git a/src/Argon.JsonPath/BooleanQueryExpression.cs b/src/Argon.JsonPath/BooleanQueryExpression.cs index 30d464736..2c75306a0 100644 --- a/src/Argon.JsonPath/BooleanQueryExpression.cs +++ b/src/Argon.JsonPath/BooleanQueryExpression.cs @@ -6,13 +6,13 @@ class BooleanQueryExpression(QueryOperator @operator, object left, object? right public readonly object Left = left; public readonly object? Right = right; - static IEnumerable GetResult(JToken root, JToken t, object? o) - { - if (o is JToken resultToken) - { - return [resultToken]; - } + // constant operands never change, so the single-element wrappers IsMatch needs + // are built once instead of per candidate token + readonly JToken[]? leftConstant = left is JToken leftToken ? [leftToken] : null; + readonly JToken[]? rightConstant = right is JToken rightToken ? [rightToken] : null; + static IEnumerable GetFilterResult(JToken root, JToken t, object? o) + { if (o is List pathFilters) { return JPath.Evaluate(pathFilters, root, t, JTokenExtensions.DefaultSettings); @@ -25,13 +25,35 @@ public override bool IsMatch(JToken root, JToken t, JsonSelectSettings settings) { if (Operator == QueryOperator.Exists) { - return GetResult(root, t, Left).Any(); + return leftConstant != null || + GetFilterResult(root, t, Left).Any(); + } + + // single constant right operand is the dominant filter shape (e.g. @.a == 1): + // compare directly without materializing result collections + if (rightConstant != null) + { + var rightResult = rightConstant[0]; + if (leftConstant != null) + { + return MatchTokens(leftConstant[0], rightResult, settings); + } + + foreach (var leftResult in GetFilterResult(root, t, Left)) + { + if (MatchTokens(leftResult, rightResult, settings)) + { + return true; + } + } + + return false; } - using var leftResults = GetResult(root, t, Left).GetEnumerator(); + using var leftResults = (leftConstant ?? GetFilterResult(root, t, Left)).GetEnumerator(); if (leftResults.MoveNext()) { - var rightResultsEn = GetResult(root, t, Right); + var rightResultsEn = GetFilterResult(root, t, Right); var rightResults = rightResultsEn as ICollection ?? rightResultsEn.ToList(); do diff --git a/src/Argon.JsonPath/JTokenExtensions.cs b/src/Argon.JsonPath/JTokenExtensions.cs index 717bfa197..ecdcc733e 100644 --- a/src/Argon.JsonPath/JTokenExtensions.cs +++ b/src/Argon.JsonPath/JTokenExtensions.cs @@ -1,8 +1,34 @@ -/// +using System.Collections.Concurrent; + +/// /// Extensions to . /// public static class JTokenExtensions { + // A parsed JPath is immutable and evaluation holds no per-query state, so parsing + // (per-segment substrings + filter tree) can be paid once per distinct expression. + // The wholesale clear keeps the cache bounded for pathological dynamic paths. + const int pathCacheLimit = 512; + static readonly ConcurrentDictionary pathCache = new(StringComparer.Ordinal); + + static JPath ParsePath(string path) + { + if (pathCache.TryGetValue(path, out var jPath)) + { + return jPath; + } + + jPath = new(path); + + if (pathCache.Count >= pathCacheLimit) + { + pathCache.Clear(); + } + + pathCache[path] = jPath; + return jPath; + } + /// /// Selects a using a JSONPath expression. Selects the token that matches the object path. /// @@ -37,7 +63,7 @@ public static class JTokenExtensions /// A . public static JToken? SelectToken(this JToken token, string path, JsonSelectSettings? settings) { - var jPath = new JPath(path); + var jPath = ParsePath(path); settings ??= DefaultSettings; JToken? result = null; @@ -95,7 +121,7 @@ public static IEnumerable SelectTokens(this JToken token, string path, J { settings ??= DefaultSettings; - var p = new JPath(path); + var p = ParsePath(path); return p.Evaluate(token, token, settings); } } \ No newline at end of file diff --git a/src/Argon/JsonSerializer.cs b/src/Argon/JsonSerializer.cs index 2c6cd41b6..3f943a348 100644 --- a/src/Argon/JsonSerializer.cs +++ b/src/Argon/JsonSerializer.cs @@ -685,8 +685,11 @@ internal IReferenceResolver GetReferenceResolver() => { if (converters != null) { - foreach (var converter in converters) + // indexed loop: foreach over IList boxes the List enumerator, and this + // runs for every value serialized/deserialized + for (var i = 0; i < converters.Count; i++) { + var converter = converters[i]; if (converter.CanConvert(type)) { return converter; diff --git a/src/Argon/JsonTextReader.cs b/src/Argon/JsonTextReader.cs index 2844cea8b..ca832f680 100644 --- a/src/Argon/JsonTextReader.cs +++ b/src/Argon/JsonTextReader.cs @@ -109,6 +109,7 @@ void ParseReadString(char quote, ReadType readType) break; case ReadType.ReadAsInt32: case ReadType.ReadAsDecimal: + case ReadType.ReadAsDouble: case ReadType.ReadAsBoolean: // caller will convert result break; @@ -647,7 +648,8 @@ JsonReaderException CreateUnexpectedCharacterException(char c) => break; case '"': case '\'': - ParseString(currentChar, ReadType.Read); + // ReadAsBoolean skips the intermediate string token; the span is re-parsed below + ParseString(currentChar, ReadType.ReadAsBoolean); return ReadBooleanString(stringReference.AsSpan()); case 'n': HandleNull(); @@ -1724,7 +1726,7 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition) if (singleDigit) { // digit char values start at 48 - numberValue = (decimal) firstChar - 48; + numberValue = BoxedPrimitives.Get((decimal) firstChar - 48); } else { @@ -1747,13 +1749,11 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition) if (singleDigit) { // digit char values start at 48 - numberValue = (double) firstChar - 48; + numberValue = BoxedPrimitives.Get((double) firstChar - 48); } else { - var number = stringReference.ToString(); - - if (double.TryParse(number, NumberStyles.Float, InvariantCulture, out var value)) + if (double.TryParse(stringReference.AsSpan(), NumberStyles.Float, InvariantCulture, out var value)) { numberValue = BoxedPrimitives.Get(value); } @@ -1772,7 +1772,7 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition) if (singleDigit) { // digit char values start at 48 - numberValue = (long) firstChar - 48; + numberValue = BoxedPrimitives.Get((long) firstChar - 48); numberType = JsonToken.Integer; } else @@ -1811,9 +1811,7 @@ void ParseReadNumber(ReadType readType, char firstChar, int initialPosition) } else { - var number = stringReference.ToString(); - - if (double.TryParse(number, NumberStyles.Float, InvariantCulture, out var d)) + if (double.TryParse(stringReference.AsSpan(), NumberStyles.Float, InvariantCulture, out var d)) { numberValue = BoxedPrimitives.Get(d); } diff --git a/src/Argon/JsonTextWriter.cs b/src/Argon/JsonTextWriter.cs index d982c0bc0..85feffa82 100644 --- a/src/Argon/JsonTextWriter.cs +++ b/src/Argon/JsonTextWriter.cs @@ -136,8 +136,16 @@ protected override void WriteEnd(JsonToken token) /// /// Writes the property name of a name/value pair on a JSON object. /// - public override void WritePropertyName(string name) => - WritePropertyName(name.AsSpan()); + public override void WritePropertyName(string name) + { + // keep the original string: routing through the span overload would + // re-materialize it in InternalWritePropertyName for position tracking + InternalWritePropertyName(name); + + WriteEscapedString(name.AsSpan(), QuoteName); + + writer.Write(':'); + } /// /// Writes the property name of a name/value pair on a JSON object. @@ -155,8 +163,31 @@ public override void WritePropertyName(CharSpan name) /// Writes the property name of a name/value pair on a JSON object. /// /// A flag to indicate whether the text should be escaped when it is written as a JSON property name. - public override void WritePropertyName(string name, bool escape) => - WritePropertyName(name.AsSpan(), escape); + public override void WritePropertyName(string name, bool escape) + { + InternalWritePropertyName(name); + + if (escape) + { + WriteEscapedString(name.AsSpan(), QuoteName); + } + else + { + if (QuoteName) + { + writer.Write(quoteChar); + } + + writer.Write(name); + + if (QuoteName) + { + writer.Write(quoteChar); + } + } + + writer.Write(':'); + } /// /// Writes the property name of a name/value pair on a JSON object. @@ -382,7 +413,7 @@ public override void WriteValue(ulong value) public override void WriteValue(float value) { InternalWriteValue(JsonToken.Float); - WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, false, FloatFormat)); + WriteFloatValue(value, false); } /// @@ -397,7 +428,7 @@ public override void WriteValue(float? value) else { InternalWriteValue(JsonToken.Float); - WriteValueInternal(JsonConvert.ToString(value.GetValueOrDefault(), FloatFormatHandling, QuoteChar, true, FloatFormat)); + WriteFloatValue(value.GetValueOrDefault(), true); } } @@ -407,7 +438,7 @@ public override void WriteValue(float? value) public override void WriteValue(double value) { InternalWriteValue(JsonToken.Float); - WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, false, FloatFormat)); + WriteDoubleValue(value, false); } /// @@ -422,7 +453,7 @@ public override void WriteValue(double? value) else { InternalWriteValue(JsonToken.Float); - WriteValueInternal(JsonConvert.ToString(value.GetValueOrDefault(), FloatFormatHandling, QuoteChar, true, FloatFormat)); + WriteDoubleValue(value.GetValueOrDefault(), true); } } @@ -486,7 +517,24 @@ public override void WriteValue(sbyte value) public override void WriteValue(decimal value) { InternalWriteValue(JsonToken.Float); - WriteValueInternal(JsonConvert.ToString(value)); + + EnsureBuffer(); + var buffer = writeBuffer!; + // reserve two chars so the decimal place can always be appended in-buffer + if (value.TryFormat(buffer.AsSpan(0, buffer.Length - 2), out var written, default, InvariantCulture)) + { + if (buffer.AsSpan(0, written).IndexOf('.') == -1) + { + buffer[written++] = '.'; + buffer[written++] = '0'; + } + + writer.Write(buffer, 0, written); + } + else + { + WriteValueInternal(JsonConvert.ToString(value)); + } } /// @@ -585,19 +633,23 @@ public override void WriteValue(Guid value) { InternalWriteValue(JsonToken.String); - var text = value.ToString("D", InvariantCulture); - + // 36 chars for the "D" format plus the quotes + var buffer = EnsureBuffer(38, 0); + var pos = 0; if (QuoteValue) { - writer.Write(quoteChar); + buffer[pos++] = quoteChar; } - writer.Write(text); + value.TryFormat(buffer.AsSpan(pos), out var written); + pos += written; if (QuoteValue) { - writer.Write(quoteChar); + buffer[pos++] = quoteChar; } + + writer.Write(buffer, 0, pos); } /// @@ -607,19 +659,24 @@ public override void WriteValue(TimeSpan value) { InternalWriteValue(JsonToken.String); - var text = value.ToString(null, InvariantCulture); - + // constant "c" format is at most 26 chars plus the quotes + EnsureBuffer(); + var buffer = writeBuffer!; + var pos = 0; if (QuoteValue) { - writer.Write(quoteChar); + buffer[pos++] = quoteChar; } - writer.Write(text); + value.TryFormat(buffer.AsSpan(pos), out var written, "c"); + pos += written; if (QuoteValue) { - writer.Write(quoteChar); + buffer[pos++] = quoteChar; } + + writer.Write(buffer, 0, pos); } /// @@ -666,6 +723,61 @@ void EnsureBuffer() => // maximum buffer sized used when writing iso date writeBuffer ??= BufferUtils.RentBuffer(35); + void WriteDoubleValue(double value, bool nullable) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat)); + return; + } + + EnsureBuffer(); + var buffer = writeBuffer!; + // reserve two chars so the decimal place can always be appended in-buffer; + // a custom FloatPrecision format that overflows falls back to the string path + if (value.TryFormat(buffer.AsSpan(0, buffer.Length - 2), out var written, FloatFormat, InvariantCulture)) + { + written = EnsureDecimalPlace(buffer, written); + writer.Write(buffer, 0, written); + } + else + { + WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat)); + } + } + + void WriteFloatValue(float value, bool nullable) + { + if (float.IsNaN(value) || float.IsInfinity(value)) + { + WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat)); + return; + } + + EnsureBuffer(); + var buffer = writeBuffer!; + if (value.TryFormat(buffer.AsSpan(0, buffer.Length - 2), out var written, FloatFormat, InvariantCulture)) + { + written = EnsureDecimalPlace(buffer, written); + writer.Write(buffer, 0, written); + } + else + { + WriteValueInternal(JsonConvert.ToString(value, FloatFormatHandling, QuoteChar, nullable, FloatFormat)); + } + } + + static int EnsureDecimalPlace(char[] buffer, int written) + { + if (buffer.AsSpan(0, written).IndexOfAny('.', 'E', 'e') == -1) + { + buffer[written++] = '.'; + buffer[written++] = '0'; + } + + return written; + } + internal char[] EnsureBuffer(int length, int copyTo) { if (length < 35) diff --git a/src/Argon/Linq/JContainer.cs b/src/Argon/Linq/JContainer.cs index 51ef10559..575ba6a28 100644 --- a/src/Argon/Linq/JContainer.cs +++ b/src/Argon/Linq/JContainer.cs @@ -23,11 +23,12 @@ internal JContainer() internal JContainer(JContainer other) : this() { - var i = 0; - foreach (var child in other) + // indexed loop: enumerating the container goes through IList and + // boxes an enumerator per cloned container + var children = other.ChildrenTokens; + for (var i = 0; i < children.Count; i++) { - TryAddInternal(i, child, false); - i++; + TryAddInternal(i, children[i], false); } SetLineInfo(this, null); @@ -130,16 +131,31 @@ IEnumerable GetDescendants(bool self) yield return this; } - foreach (var o in ChildrenTokens) + // pre-order pointer walk over the sibling/parent links: a single iterator for + // the whole traversal instead of nested iterators per container depth + var current = First; + while (current != null) { - yield return o; - if (o is JContainer c) + yield return current; + + if (current is JContainer {HasValues: true} container) { - foreach (var d in c.Descendants()) - { - yield return d; - } + current = container.First; + continue; + } + + // climb out of finished containers + while (current != null && current != this && current == current.Parent!.Last) + { + current = current.Parent; + } + + if (current == null || current == this) + { + yield break; } + + current = current.Next; } } @@ -527,7 +543,22 @@ void ReadContentFrom(JsonReader r, JsonLoadSettings? settings) case JsonToken.Date: case JsonToken.Boolean: case JsonToken.Bytes: - var v = new JValue(r.Value); + var value = r.Value; + // for the typical value shapes the token type already determines the + // JTokenType, skipping the type-test ladder in the JValue(object) ctor; + // unusual shapes (custom readers) keep the general path + var knownType = r.TokenType switch + { + JsonToken.String when value is string => JTokenType.String, + JsonToken.Integer when value is long or int or ulong or BigInteger => JTokenType.Integer, + JsonToken.Float when value is double or decimal or float => JTokenType.Float, + JsonToken.Date when value is DateTime or DateTimeOffset => JTokenType.Date, + JsonToken.Boolean when value is bool => JTokenType.Boolean, + _ => JTokenType.None + }; + var v = knownType == JTokenType.None + ? new JValue(value) + : new JValue(value, knownType); v.SetLineInfo(lineInfo, settings); parent.Add(v); break; @@ -564,22 +595,17 @@ static JProperty ReadProperty(JsonReader reader, JsonLoadSettings? settings, IJs { var parentObject = (JObject) parent; var propertyName = (string) reader.GetValue(); - var existingPropertyWithName = parentObject.PropertyOrNull(propertyName); - if (existingPropertyWithName != null) - { - throw JsonReaderException.Create(reader, $"Property with the name '{propertyName}' already exists in the current JSON object."); - } - var property = new JProperty(propertyName); property.SetLineInfo(lineInfo, settings); - // handle multiple properties with the same name in JSON - if (existingPropertyWithName == null) + try { - parent.Add(property); + // JObject.ValidateToken rejects duplicate names, so a separate + // pre-check would hash the name a second time on every property + parentObject.Add(property); } - else + catch (ArgumentException) { - existingPropertyWithName.Replace(property); + throw JsonReaderException.Create(reader, $"Property with the name '{propertyName}' already exists in the current JSON object."); } return property; diff --git a/src/Argon/Linq/JObject.cs b/src/Argon/Linq/JObject.cs index b8e105244..9c36f4a20 100644 --- a/src/Argon/Linq/JObject.cs +++ b/src/Argon/Linq/JObject.cs @@ -353,7 +353,7 @@ public override void WriteTo(JsonWriter writer, params IList conv { writer.WriteStartObject(); - foreach (var property in properties) + foreach (var property in properties.InnerList) { property.WriteTo(writer, converters); } @@ -535,8 +535,9 @@ internal override int GetDeepHashCode() => /// public IEnumerator> GetEnumerator() { - foreach (JProperty property in properties) + foreach (var token in properties.InnerList) { + var property = (JProperty) token; yield return new(property.Name, property.Value); } } diff --git a/src/Argon/Linq/JPropertyKeyedCollection.cs b/src/Argon/Linq/JPropertyKeyedCollection.cs index 763eff741..0ef18060f 100644 --- a/src/Argon/Linq/JPropertyKeyedCollection.cs +++ b/src/Argon/Linq/JPropertyKeyedCollection.cs @@ -129,6 +129,10 @@ public ICollection Values } } + // Same backing list Collection wraps, exposed so hot loops can foreach + // without boxing the enumerator through the IList interface dispatch. + internal List InnerList => (List) Items; + public int IndexOfReference(JToken t) => ((List) Items).IndexOfReference(t); diff --git a/src/Argon/Linq/JToken.cs b/src/Argon/Linq/JToken.cs index 66f3d6a50..8e5617245 100644 --- a/src/Argon/Linq/JToken.cs +++ b/src/Argon/Linq/JToken.cs @@ -419,7 +419,10 @@ public void Replace(JToken value) [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "WriteTo without converters is safe.")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "WriteTo without converters is safe.")] public void WriteTo(JsonWriter writer) => - WriteTo(writer, []); + // a collection expression here would allocate a fresh list per call + WriteTo(writer, emptyConverters); + + static readonly JsonConverter[] emptyConverters = []; /// /// Writes this token to a . diff --git a/src/Argon/Serialization/JsonObjectContract.cs b/src/Argon/Serialization/JsonObjectContract.cs index 623702a45..1f4167d0f 100644 --- a/src/Argon/Serialization/JsonObjectContract.cs +++ b/src/Argon/Serialization/JsonObjectContract.cs @@ -79,6 +79,47 @@ internal bool HasRequiredOrDefaultValueProperties } } + List? presenceRelevantProperties; + List? nonIgnoredProperties; + + // Presence tracking only matters for properties EndProcessProperty can act on: required + // (directly, or for every property via the ItemRequired / serializer-global Populate + // fallbacks) or default-value populated. Ignored properties are excluded - the end + // sweep is a no-op for them. + internal List GetPresenceRelevantProperties(bool globalPopulate) + { + if (globalPopulate || + ItemRequired.GetValueOrDefault(Required.Default) != Required.Default) + { + return nonIgnoredProperties ??= BuildPropertyList(requireRelevance: false); + } + + return presenceRelevantProperties ??= BuildPropertyList(requireRelevance: true); + } + + List BuildPropertyList(bool requireRelevance) + { + var list = new List(); + foreach (var property in Properties.List) + { + if (property.Ignored) + { + continue; + } + + if (requireRelevance && + property.Required == Required.Default && + (property.DefaultValueHandling & DefaultValueHandling.Populate) != DefaultValueHandling.Populate) + { + continue; + } + + list.Add(property); + } + + return list; + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Argon/Serialization/JsonPropertyCollection.cs b/src/Argon/Serialization/JsonPropertyCollection.cs index f4b8effc0..d9e349f4a 100644 --- a/src/Argon/Serialization/JsonPropertyCollection.cs +++ b/src/Argon/Serialization/JsonPropertyCollection.cs @@ -25,6 +25,10 @@ public JsonPropertyCollection(Type type) list = (List) Items; } + // Same backing list, exposed so hot serializer loops can foreach without + // boxing the enumerator through Collection's interface dispatch. + internal List List => list; + internal void AddRange(IList collection) { foreach (var value in collection) diff --git a/src/Argon/Serialization/JsonSerializerInternalReader.cs b/src/Argon/Serialization/JsonSerializerInternalReader.cs index 0c65af08b..9828c6100 100644 --- a/src/Argon/Serialization/JsonSerializerInternalReader.cs +++ b/src/Argon/Serialization/JsonSerializerInternalReader.cs @@ -1663,19 +1663,27 @@ object CreateObjectUsingCreatorWithParameters(JsonReader reader, JsonObjectContr var propertyContexts = ResolvePropertyAndCreatorValues(contract, containerProperty, reader, contract.UnderlyingType); if (trackPresence) { - foreach (var property in contract.Properties) + // hash-set membership instead of scanning the context list per property + var seenProperties = new HashSet(); + foreach (var context in propertyContexts) { - if (!property.Ignored) + if (context.Property != null) { - if (propertyContexts.All(_ => _.Property != property)) - { - propertyContexts.Add( - new() - { - Property = property, - Presence = PropertyPresence.None - }); - } + seenProperties.Add(context.Property); + } + } + + foreach (var property in contract.Properties.List) + { + if (!property.Ignored && + !seenProperties.Contains(property)) + { + propertyContexts.Add( + new() + { + Property = property, + Presence = PropertyPresence.None + }); } } } @@ -2009,11 +2017,19 @@ object PopulateObject(object newObject, JsonReader reader, JsonObjectContract co { OnDeserializing(reader, newObject); - // only need to keep a track of properties' presence if they are required or a value should be defaulted if missing - var propertiesPresence = contract.HasRequiredOrDefaultValueProperties || - HasFlag(Serializer.DefaultValueHandling, DefaultValueHandling.Populate) - ? contract.Properties.ToDictionary(_ => _, _ => PropertyPresence.None) - : null; + // only need to keep a track of properties' presence if they are required or a value should be defaulted if missing, + // and only for the properties EndProcessProperty can act on + var globalPopulate = HasFlag(Serializer.DefaultValueHandling, DefaultValueHandling.Populate); + Dictionary? propertiesPresence = null; + if (contract.HasRequiredOrDefaultValueProperties || globalPopulate) + { + var relevantProperties = contract.GetPresenceRelevantProperties(globalPopulate); + propertiesPresence = new(relevantProperties.Count); + foreach (var property in relevantProperties) + { + propertiesPresence[property] = PropertyPresence.None; + } + } if (id != null) { @@ -2211,7 +2227,9 @@ Required.AllowNull or static void SetPropertyPresence(JsonReader reader, JsonProperty property, Dictionary? requiredProperties) { - if (requiredProperties == null) + // the dictionary only tracks presence-relevant properties; do not add others back + if (requiredProperties == null || + !requiredProperties.ContainsKey(property)) { return; } diff --git a/src/Argon/Serialization/JsonSerializerInternalWriter.cs b/src/Argon/Serialization/JsonSerializerInternalWriter.cs index f5aef7f79..d719b0719 100644 --- a/src/Argon/Serialization/JsonSerializerInternalWriter.cs +++ b/src/Argon/Serialization/JsonSerializerInternalWriter.cs @@ -390,7 +390,7 @@ void SerializeObject(JsonWriter writer, object value, JsonObjectContract contrac var initialDepth = writer.Top; - foreach (var property in contract.Properties) + foreach (var property in contract.Properties.List) { try { @@ -755,7 +755,7 @@ void SerializeDynamic(JsonWriter writer, IDynamicMetaObjectProvider value, JsonD var initialDepth = writer.Top; - foreach (var property in contract.Properties) + foreach (var property in contract.Properties.List) { // only write non-dynamic properties that have an explicit attribute if (property.HasMemberAttribute) @@ -902,9 +902,18 @@ void SerializeDictionary(JsonWriter writer, IDictionary values, JsonDictionaryCo static IEnumerable Items(IDictionary values) { - foreach (DictionaryEntry entry in values) + // manual IDictionaryEnumerator use: foreach boxes a DictionaryEntry per item + var e = values.GetEnumerator(); + try + { + while (e.MoveNext()) + { + yield return e.Entry; + } + } + finally { - yield return entry; + (e as IDisposable)?.Dispose(); } } @@ -927,9 +936,19 @@ static IEnumerable Items(IDictionary values) } else { - foreach (DictionaryEntry entry in values) + // manual IDictionaryEnumerator use: foreach boxes a DictionaryEntry per item + var e = values.GetEnumerator(); + try + { + while (e.MoveNext()) + { + var entry = e.Entry; + SerializeDictionaryItem(writer, contract, member, entry.Key, entry.Value, keyContract, underlying); + } + } + finally { - SerializeDictionaryItem(writer, contract, member, entry.Key, entry.Value, keyContract, underlying); + (e as IDisposable)?.Dispose(); } } diff --git a/src/Argon/Serialization/JsonTypeReflector.cs b/src/Argon/Serialization/JsonTypeReflector.cs index 6c1e625c4..2c82e425c 100644 --- a/src/Argon/Serialization/JsonTypeReflector.cs +++ b/src/Argon/Serialization/JsonTypeReflector.cs @@ -19,32 +19,47 @@ private static class CreatorCache internal static readonly ThreadSafeStore Instance = new(GetCreator); } - [RequiresUnreferencedCode("Generic TypeConverters may require the generic types to be annotated. For example, NullableConverter requires the underlying type to be DynamicallyAccessedMembers All.")] - public static bool TryGetStringConverter( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type, - [NotNullWhen(true)] out TypeConverter? typeConverter) + [RequiresUnreferencedCode(MiscellaneousUtils.TrimWarning)] + static class StringConverterCache { - typeConverter = TypeDescriptor.GetConverter(type); - - // use the type's TypeConverter can convert to a string - var converterType = typeConverter.GetType(); + // TypeDescriptor.GetConverter costs ~340 ns and runs per value written for + // string-contract types (Uri etc.); the answer for a type never changes within + // the contract cache's lifetime, so cache it (null = cannot convert to string). + internal static readonly ThreadSafeStore Instance = new(Create); - var converterName = converterType.FullName; - if (converterType == typeof(TypeConverter) || - string.Equals(converterName, "System.ComponentModel.ComponentConverter", StringComparison.Ordinal) || - string.Equals(converterName, "System.ComponentModel.ReferenceConverter", StringComparison.Ordinal) || - string.Equals(converterName, "System.Windows.Forms.Design.DataSourceConverter", StringComparison.Ordinal)) + [RequiresUnreferencedCode("Generic TypeConverters may require the generic types to be annotated. For example, NullableConverter requires the underlying type to be DynamicallyAccessedMembers All.")] + static TypeConverter? Create(Type type) { - return false; - } + var typeConverter = TypeDescriptor.GetConverter(type); - if (typeConverter.CanConvertTo(typeof(string))) - { - return true; + // use the type's TypeConverter can convert to a string + var converterType = typeConverter.GetType(); + + var converterName = converterType.FullName; + if (converterType == typeof(TypeConverter) || + string.Equals(converterName, "System.ComponentModel.ComponentConverter", StringComparison.Ordinal) || + string.Equals(converterName, "System.ComponentModel.ReferenceConverter", StringComparison.Ordinal) || + string.Equals(converterName, "System.Windows.Forms.Design.DataSourceConverter", StringComparison.Ordinal)) + { + return null; + } + + if (typeConverter.CanConvertTo(typeof(string))) + { + return typeConverter; + } + + return null; } + } - typeConverter = null; - return false; + [RequiresUnreferencedCode("Generic TypeConverters may require the generic types to be annotated. For example, NullableConverter requires the underlying type to be DynamicallyAccessedMembers All.")] + public static bool TryGetStringConverter( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type, + [NotNullWhen(true)] out TypeConverter? typeConverter) + { + typeConverter = StringConverterCache.Instance.Get(type); + return typeConverter != null; } [RequiresUnreferencedCode(MiscellaneousUtils.TrimWarning)] diff --git a/src/Argon/Utilities/ConvertUtils.cs b/src/Argon/Utilities/ConvertUtils.cs index 361c6c2d3..e0b2e1835 100644 --- a/src/Argon/Utilities/ConvertUtils.cs +++ b/src/Argon/Utilities/ConvertUtils.cs @@ -531,6 +531,23 @@ public static bool IsInteger(object value) => _ => false }; + // Overload for when the type code is already known - the object overload would box the + // enum and re-derive its own type code through reflection. Includes the nullable codes: + // contracts for nullable types carry them (a boxed value never does). + public static bool IsInteger(PrimitiveTypeCode typeCode) => + typeCode switch + { + PrimitiveTypeCode.SByte or PrimitiveTypeCode.SByteNullable or + PrimitiveTypeCode.Byte or PrimitiveTypeCode.ByteNullable or + PrimitiveTypeCode.Int16 or PrimitiveTypeCode.Int16Nullable or + PrimitiveTypeCode.UInt16 or PrimitiveTypeCode.UInt16Nullable or + PrimitiveTypeCode.Int32 or PrimitiveTypeCode.Int32Nullable or + PrimitiveTypeCode.UInt32 or PrimitiveTypeCode.UInt32Nullable or + PrimitiveTypeCode.Int64 or PrimitiveTypeCode.Int64Nullable or + PrimitiveTypeCode.UInt64 or PrimitiveTypeCode.UInt64Nullable => true, + _ => false + }; + public static ParseResult Int32TryParse(char[] chars, int start, int length, out int value) { value = 0; diff --git a/src/Argon/Utilities/DateTimeUtils.cs b/src/Argon/Utilities/DateTimeUtils.cs index 85bda8ef8..e0e608884 100644 --- a/src/Argon/Utilities/DateTimeUtils.cs +++ b/src/Argon/Utilities/DateTimeUtils.cs @@ -118,12 +118,11 @@ internal static bool TryParseDateTimeOffset(string s, out DateTimeOffset dt) { if (s.Length is >= 19 and <= 40 && char.IsDigit(s[0]) && s[10] == 'T') { + // TryParseExact fully validates and produces the same result the custom ISO + // parser would; re-parsing (and the ToCharArray copy) was pure overhead. if (DateTimeOffset.TryParseExact(s, IsoDateFormat, InvariantCulture, DateTimeStyles.RoundtripKind, out dt)) { - if (TryParseDateTimeOffsetIso(new(s.ToCharArray(), 0, s.Length), out dt)) - { - return true; - } + return true; } } } diff --git a/src/Argon/Utilities/DictionaryWrapper.cs b/src/Argon/Utilities/DictionaryWrapper.cs index f99d843a5..8aa764b54 100644 --- a/src/Argon/Utilities/DictionaryWrapper.cs +++ b/src/Argon/Utilities/DictionaryWrapper.cs @@ -446,11 +446,11 @@ object IDictionary.this[object key] readonly struct DictionaryEnumerator(IEnumerator> e) : IDictionaryEnumerator { - public DictionaryEntry Entry => (DictionaryEntry) Current; + public DictionaryEntry Entry => new(e.Current.Key, e.Current.Value); - public object Key => Entry.Key; + public object Key => e.Current.Key; - public object Value => Entry.Value; + public object Value => e.Current.Value; public object Current => new DictionaryEntry(e.Current.Key, e.Current.Value); diff --git a/src/Argon/Utilities/EnumUtils.cs b/src/Argon/Utilities/EnumUtils.cs index 52055dc1d..3631f5064 100644 --- a/src/Argon/Utilities/EnumUtils.cs +++ b/src/Argon/Utilities/EnumUtils.cs @@ -7,12 +7,16 @@ static class EnumUtils const char EnumSeparatorChar = ','; const string EnumSeparatorString = ", "; - static readonly ThreadSafeStore, EnumInfo> ValuesAndNamesPerEnum = new(InitializeValuesAndNames); + // struct key: a Tuple class key would allocate on every cache probe, + // i.e. once per enum value read or written + readonly record struct EnumKey(Type EnumType, NamingStrategy? NamingStrategy); + + static readonly ThreadSafeStore ValuesAndNamesPerEnum = new(InitializeValuesAndNames); [UnconditionalSuppressMessage("TrimAnalysis", "IL2075", Justification = "Enum fields are not trimmed")] - static EnumInfo InitializeValuesAndNames(Tuple key) + static EnumInfo InitializeValuesAndNames(EnumKey key) { - var enumType = key.Item1; + var enumType = key.EnumType; var names = Enum.GetNames(enumType); var resolvedNames = new string[names.Length]; var values = new ulong[names.Length]; @@ -35,9 +39,9 @@ static EnumInfo InitializeValuesAndNames(Tuple key) throw new InvalidOperationException($"Enum name '{resolvedName}' already exists on enum '{enumType.Name}'."); } - resolvedNames[i] = key.Item2 == null + resolvedNames[i] = key.NamingStrategy == null ? resolvedName - : key.Item2.GetPropertyName(resolvedName, hasSpecifiedName); + : key.NamingStrategy.GetPropertyName(resolvedName, hasSpecifiedName); } var isFlags = enumType.IsDefined(typeof(FlagsAttribute), false); diff --git a/src/Argon/Utilities/JavaScriptUtils.cs b/src/Argon/Utilities/JavaScriptUtils.cs index 02cf2bec8..15152fd66 100644 --- a/src/Argon/Utilities/JavaScriptUtils.cs +++ b/src/Argon/Utilities/JavaScriptUtils.cs @@ -36,10 +36,61 @@ static JavaScriptUtils() { htmlEscapeFlags[escapeChar] = true; } + +#if NET8_0_OR_GREATER + // must run after the flag arrays are populated (field initializers would run first) + singleQuoteSearchValues = BuildSearchValues(SingleQuoteEscapeFlags); + doubleQuoteSearchValues = BuildSearchValues(DoubleQuoteEscapeFlags); + htmlSearchValues = BuildSearchValues(htmlEscapeFlags); +#endif } const string escapedUnicodeText = "!"; +#if NET8_0_OR_GREATER + static readonly SearchValues singleQuoteSearchValues; + static readonly SearchValues doubleQuoteSearchValues; + static readonly SearchValues htmlSearchValues; + + static SearchValues BuildSearchValues(bool[] flags) + { + var chars = new List(); + for (var i = 0; i < flags.Length; i++) + { + if (flags[i]) + { + chars.Add((char) i); + } + } + + // high chars FirstCharToEscape treats as escapes outside the flag range + chars.Add('\u0085'); + chars.Add('\u2028'); + chars.Add('\u2029'); + return SearchValues.Create(chars.ToArray()); + } + + static SearchValues? TryGetSearchValues(bool[] escapeFlags) + { + if (ReferenceEquals(escapeFlags, DoubleQuoteEscapeFlags)) + { + return doubleQuoteSearchValues; + } + + if (ReferenceEquals(escapeFlags, SingleQuoteEscapeFlags)) + { + return singleQuoteSearchValues; + } + + if (ReferenceEquals(escapeFlags, htmlEscapeFlags)) + { + return htmlSearchValues; + } + + return null; + } +#endif + public static bool[] GetCharEscapeFlags(EscapeHandling escapeHandling, char quoteChar) { if (escapeHandling == EscapeHandling.None) @@ -115,17 +166,10 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu if (lastWritePosition != 0) { - if (buffer == null || buffer.Length < lastWritePosition) - { - buffer = BufferUtils.EnsureBufferSize(lastWritePosition, buffer); - } - // write unchanged chars at start of text. - value.Slice(0, lastWritePosition).CopyTo(buffer); - writer.Write(buffer, 0, lastWritePosition); + writer.Write(value.Slice(0, lastWritePosition)); } - int length; for (var i = lastWritePosition; i < value.Length; i++) { var c = value[i]; @@ -213,32 +257,8 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu if (i > lastWritePosition) { - length = i - lastWritePosition + (isEscapedUnicodeText ? unicodeTextLength : 0); - var start = isEscapedUnicodeText ? unicodeTextLength : 0; - - if (buffer == null || buffer.Length < length) - { - var newBuffer = BufferUtils.RentBuffer(length); - - // the unicode text is already in the buffer - // copy it over when creating new buffer - if (isEscapedUnicodeText) - { - MiscellaneousUtils.Assert(buffer != null, "Write buffer should never be null because it is set when the escaped unicode text is encountered."); - - Array.Copy(buffer, newBuffer, unicodeTextLength); - } - - BufferUtils.ReturnBuffer(buffer); - - buffer = newBuffer; - } - - var count = length - start; - value.Slice(lastWritePosition, count).CopyTo(buffer.AsSpan(start: start, length: count)); - // write unchanged chars before writing escaped text - writer.Write(buffer, start, count); + writer.Write(value.Slice(lastWritePosition, i - lastWritePosition)); } lastWritePosition = i + 1; @@ -253,18 +273,10 @@ static void WriteEscapedJavaScriptNonNullString(TextWriter writer, CharSpan valu } MiscellaneousUtils.Assert(lastWritePosition != 0); - length = value.Length - lastWritePosition; - if (length > 0) + if (value.Length - lastWritePosition > 0) { - if (buffer == null || buffer.Length < length) - { - buffer = BufferUtils.EnsureBufferSize(length, buffer); - } - - value.Slice(lastWritePosition, length).CopyTo(buffer); - // write remaining text - writer.Write(buffer, 0, length); + writer.Write(value.Slice(lastWritePosition)); } } @@ -280,6 +292,20 @@ public static string ToEscapedJavaScriptString(CharSpan value, char delimiter, b static int FirstCharToEscape(CharSpan value, bool[] escapeFlags, EscapeHandling escapeHandling) { +#if NET8_0_OR_GREATER + // vectorized scan for the fixed escape sets; the scalar loop stays for short + // strings (vector setup costs more than it saves there) and for EscapeNonAscii, + // where every non-ascii char is an escape target + if (value.Length >= 16 && + escapeHandling != EscapeHandling.EscapeNonAscii) + { + var searchValues = TryGetSearchValues(escapeFlags); + if (searchValues != null) + { + return value.IndexOfAny(searchValues); + } + } +#endif for (var i = 0; i != value.Length; i++) { var c = value[i]; @@ -309,4 +335,4 @@ static int FirstCharToEscape(CharSpan value, bool[] escapeFlags, EscapeHandling return -1; } -} \ No newline at end of file +} diff --git a/src/Argon/Utilities/StringUtils.cs b/src/Argon/Utilities/StringUtils.cs index ca091777f..3cd867754 100644 --- a/src/Argon/Utilities/StringUtils.cs +++ b/src/Argon/Utilities/StringUtils.cs @@ -31,34 +31,45 @@ public static void ToCharAsUnicode(char c, char[] buffer) public static JsonProperty? ForgivingCaseSensitiveFind(this JsonPropertyCollection source, string testValue) { - if (source.Count == 0) + // single allocation-free pass; runs per unmatched member for every object + // deserialized through a parameterized constructor + JsonProperty? caseInsensitiveMatch = null; + var caseInsensitiveCount = 0; + JsonProperty? caseSensitiveMatch = null; + var caseSensitiveCount = 0; + + foreach (var property in source.List) { - return null; - } + if (!string.Equals(property.PropertyName, testValue, StringComparison.OrdinalIgnoreCase)) + { + continue; + } - var caseInsensitiveResults = source.Where(_ => string.Equals(_.PropertyName, testValue, StringComparison.OrdinalIgnoreCase)) - .ToArray(); - if (caseInsensitiveResults.Length == 0) - { - return null; - } + caseInsensitiveCount++; + caseInsensitiveMatch ??= property; - if (caseInsensitiveResults.Length == 1) - { - return caseInsensitiveResults[0]; + if (string.Equals(property.PropertyName, testValue, StringComparison.Ordinal)) + { + caseSensitiveCount++; + caseSensitiveMatch ??= property; + } } - // multiple results returned. now filter using case sensitivity - var caseSensitiveResults = source.Where(_ => string.Equals(_.PropertyName, testValue, StringComparison.Ordinal)) - .ToArray(); - if (caseSensitiveResults.Length == 0) + switch (caseInsensitiveCount) { - return null; + case 0: + return null; + case 1: + return caseInsensitiveMatch; } - if (caseSensitiveResults.Length == 1) + // multiple case-insensitive results. now filter using case sensitivity + switch (caseSensitiveCount) { - return caseSensitiveResults[0]; + case 0: + return null; + case 1: + return caseSensitiveMatch; } throw new("Multiple matches found for testValue"); @@ -85,6 +96,24 @@ static string ToSeparatedCase(string s, char separator) return s; } + // no upper-case char and no space means the transform is the identity: + // lower chars, digits and separators are copied through verbatim. + // Common for dictionary keys that are already snake/kebab cased. + var needsTransform = false; + foreach (var ch in s) + { + if (ch == ' ' || char.IsUpper(ch)) + { + needsTransform = true; + break; + } + } + + if (!needsTransform) + { + return s; + } + var stringBuilder = new StringBuilder(s.Length + s.Length / 4); var state = SeparatedCaseState.Start; diff --git a/src/ArgonTests/Benchmarks/CreatorDeserializeBenchmark.cs b/src/ArgonTests/Benchmarks/CreatorDeserializeBenchmark.cs new file mode 100644 index 000000000..092451d7c --- /dev/null +++ b/src/ArgonTests/Benchmarks/CreatorDeserializeBenchmark.cs @@ -0,0 +1,56 @@ +// 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 CreatorDeserializeBenchmark +{ + const string creatorJson = """{"a":1,"b":2,"c":3,"extra1":4,"extra2":5,"extra3":6,"extra4":7,"extra5":8}"""; + string requiredJson; + + [GlobalSetup] + public void Setup() => + requiredJson = JsonConvert.SerializeObject(new RequiredHolder()); + + [Benchmark] + public CreatorRecord DeserializeThroughCreator() => + JsonConvert.DeserializeObject(creatorJson); + + [Benchmark] + public RequiredHolder DeserializeWithRequiredProperty() => + JsonConvert.DeserializeObject(requiredJson); + + public class CreatorRecord + { + public CreatorRecord(int a, int b) + { + A = a; + B = b; + } + + public int A { get; } + public int B { get; } + public int C { get; set; } + public int Extra1 { get; set; } + public int Extra2 { get; set; } + public int Extra3 { get; set; } + public int Extra4 { get; set; } + public int Extra5 { get; set; } + } + + public class RequiredHolder + { + [JsonRequired] + public int A { get; set; } = 1; + + public int B { get; set; } = 2; + public int C { get; set; } = 3; + public int D { get; set; } = 4; + public int E { get; set; } = 5; + public int F { get; set; } = 6; + public int G { get; set; } = 7; + public int H { get; set; } = 8; + } +} diff --git a/src/ArgonTests/Benchmarks/LinqBenchmarks.cs b/src/ArgonTests/Benchmarks/LinqBenchmarks.cs new file mode 100644 index 000000000..eb3cf851a --- /dev/null +++ b/src/ArgonTests/Benchmarks/LinqBenchmarks.cs @@ -0,0 +1,81 @@ +// 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 LinqBenchmarks +{ + JObject deepDocument; + string parseJson; + + [GlobalSetup] + public void Setup() + { + deepDocument = BuildDeepDocument(); + parseJson = deepDocument.ToString(Formatting.None); + } + + [Benchmark] + public int Descendants() + { + var count = 0; + foreach (var _ in deepDocument.Descendants()) + { + count++; + } + + return count; + } + + [Benchmark] + public JToken DeepClone() => + deepDocument.DeepClone(); + + [Benchmark] + public string WriteTo() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + deepDocument.WriteTo(writer); + } + + return stringWriter.ToString(); + } + + [Benchmark] + public JObject Parse() => + JObject.Parse(parseJson); + + [Benchmark] + public JToken SelectTokenRepeatedPath() => + deepDocument.SelectToken("$.child1.child2.items[2].name"); + + [Benchmark] + public List FilterQuery() => + deepDocument.SelectTokens("$..[?(@.name=='name3')]").ToList(); + + static JObject BuildDeepDocument() + { + JObject Leafs(int start) => new( + Enumerable.Range(start, 5).Select(i => new JProperty($"p{i}", i))); + + var items = new JArray(Enumerable.Range(0, 8).Select(i => new JObject( + new JProperty("name", $"name{i}"), + new JProperty("value", i * 1.5), + new JProperty("flags", new JArray(i, i + 1, i + 2)), + new JProperty("leaf", Leafs(i))))); + + return new( + new JProperty("child1", new JObject( + new JProperty("child2", new JObject( + new JProperty("items", items), + new JProperty("meta", Leafs(100)))), + new JProperty("side", new JArray(Enumerable.Range(0, 20))))), + new JProperty("top", new JArray(Enumerable.Range(0, 10).Select(i => new JObject( + new JProperty("k", $"key{i}"), + new JProperty("v", Leafs(i * 10))))))); + } +} diff --git a/src/ArgonTests/Benchmarks/ReaderBenchmarks.cs b/src/ArgonTests/Benchmarks/ReaderBenchmarks.cs new file mode 100644 index 000000000..70079762f --- /dev/null +++ b/src/ArgonTests/Benchmarks/ReaderBenchmarks.cs @@ -0,0 +1,72 @@ +// 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 ReaderBenchmarks +{ + string singleDigitJson; + string floatJson; + string quotedDoubleJson; + string dateOffsetJson; + string stringHeavyJson; + + [GlobalSetup] + public void Setup() + { + singleDigitJson = "[" + string.Join(",", Enumerable.Range(0, 200).Select(i => i % 9)) + "]"; + floatJson = "[" + string.Join(",", Enumerable.Range(0, 200).Select(i => $"{i}.5")) + "]"; + quotedDoubleJson = "[" + string.Join(",", Enumerable.Range(0, 200).Select(i => $"\"{i}.25\"")) + "]"; + dateOffsetJson = "[" + string.Join(",", Enumerable.Range(0, 100).Select(i => $"\"2024-0{i % 9 + 1}-15T10:20:30+02:00\"")) + "]"; + stringHeavyJson = "[" + string.Join(",", Enumerable.Range(0, 50).Select(i => $"\"a perfectly clean string with no escapable characters at all number {i} padded padded padded\"")) + "]"; + } + + [Benchmark] + public void ReadSingleDigitIntegers() + { + var reader = new JsonTextReader(new StringReader(singleDigitJson)); + while (reader.Read()) + { + } + } + + [Benchmark] + public void ReadFloats() + { + var reader = new JsonTextReader(new StringReader(floatJson)); + while (reader.Read()) + { + } + } + + [Benchmark] + public void ReadQuotedAsDouble() + { + var reader = new JsonTextReader(new StringReader(quotedDoubleJson)); + reader.Read(); + while (reader.ReadAsDouble() != null) + { + } + } + + [Benchmark] + public void ReadDateTimeOffsets() + { + var reader = new JsonTextReader(new StringReader(dateOffsetJson)); + reader.Read(); + while (reader.ReadAsDateTimeOffset() != null) + { + } + } + + [Benchmark] + public void ReadStringHeavy() + { + var reader = new JsonTextReader(new StringReader(stringHeavyJson)); + while (reader.Read()) + { + } + } +} diff --git a/src/ArgonTests/Benchmarks/SatelliteBenchmarks.cs b/src/ArgonTests/Benchmarks/SatelliteBenchmarks.cs new file mode 100644 index 000000000..380454e4c --- /dev/null +++ b/src/ArgonTests/Benchmarks/SatelliteBenchmarks.cs @@ -0,0 +1,78 @@ +// 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 System.Data; +using BenchmarkDotNet.Attributes; +using Microsoft.FSharp.Collections; + +[MemoryDiagnoser] +public class SatelliteBenchmarks +{ + DataTable table; + JsonSerializerSettings dataTableSettings; + string fsharpListJson; + JsonConverter[] fsharpConverters; + JsonSerializerSettings fsharpTaxSettings; + List pocoList; + + [GlobalSetup] + public void Setup() + { + table = new(); + for (var c = 0; c < 10; c++) + { + table.Columns.Add($"ColumnName{c}", typeof(int)); + } + + for (var r = 0; r < 500; r++) + { + table.Rows.Add(Enumerable.Range(0, 10).Cast().ToArray()); + } + + dataTableSettings = new() + { + ContractResolver = new DefaultContractResolver {NamingStrategy = new CamelCaseNamingStrategy()} + }; + dataTableSettings.Converters.Add(new Argon.DataSets.DataTableConverter()); + + fsharpListJson = "[" + string.Join(",", Enumerable.Range(0, 50)) + "]"; + fsharpConverters = FSharpConverters.Instances; + fsharpTaxSettings = new(); + foreach (var converter in fsharpConverters) + { + fsharpTaxSettings.Converters.Add(converter); + } + + pocoList = Enumerable.Range(0, 20).Select(i => new SatellitePoco + { + Name = $"name{i}", + Count = i + }).ToList(); + } + + [Benchmark] + public string SerializeDataTable() => + JsonConvert.SerializeObject(table, dataTableSettings); + + [Benchmark] + public FSharpList DeserializeFSharpList() => + JsonConvert.DeserializeObject>(fsharpListJson, fsharpConverters); + + [Benchmark] + public string FSharpMapRoundtrip() + { + var map = JsonConvert.DeserializeObject>("""{"a":1,"b":2,"c":3}""", fsharpConverters); + return JsonConvert.SerializeObject(map, fsharpConverters); + } + + [Benchmark] + public string SerializePocoWithFSharpConvertersRegistered() => + JsonConvert.SerializeObject(pocoList, fsharpTaxSettings); + + public class SatellitePoco + { + public string Name { get; set; } + public int Count { get; set; } + } +} diff --git a/src/ArgonTests/Benchmarks/SerializerEngineBenchmarks.cs b/src/ArgonTests/Benchmarks/SerializerEngineBenchmarks.cs new file mode 100644 index 000000000..aa73a1243 --- /dev/null +++ b/src/ArgonTests/Benchmarks/SerializerEngineBenchmarks.cs @@ -0,0 +1,111 @@ +// 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 SerializerEngineBenchmarks +{ + List pocoList; + string pocoJson; + JsonSerializerSettings converterSettings; + Dictionary dictionary; + Dictionary snakeDictionary; + JsonSerializerSettings snakeSettings; + List uriHolders; + string enumJson; + DayOfWeek[] enumArray; + + [GlobalSetup] + public void Setup() + { + pocoList = Enumerable.Range(0, 20).Select(i => new EnginePoco + { + Name = $"name{i}", + Description = $"some description text {i}", + Count = i, + Value = i * 1.5, + Ratio = 0.25m, + Enabled = (i & 1) == 0 + }).ToList(); + pocoJson = JsonConvert.SerializeObject(pocoList); + + converterSettings = new(); + converterSettings.Converters.Add(new NoOpConverter1()); + converterSettings.Converters.Add(new NoOpConverter2()); + converterSettings.Converters.Add(new NoOpConverter3()); + + dictionary = Enumerable.Range(0, 50).ToDictionary(i => $"key_number_{i}", i => $"value {i}"); + snakeDictionary = Enumerable.Range(0, 50).ToDictionary(i => $"already_snake_key_{i}", i => i); + snakeSettings = new() + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy(processDictionaryKeys: true, overrideSpecifiedNames: false) + } + }; + + uriHolders = Enumerable.Range(0, 20).Select(i => new UriHolder {Url = new($"https://example.com/{i}")}).ToList(); + enumJson = "[" + string.Join(",", Enumerable.Range(0, 100).Select(i => i % 3)) + "]"; + enumArray = Enumerable.Range(0, 100).Select(i => (DayOfWeek) (i % 7)).ToArray(); + } + + [Benchmark] + public string SerializePocoPlain() => + JsonConvert.SerializeObject(pocoList); + + [Benchmark] + public string SerializePocoWithConverters() => + JsonConvert.SerializeObject(pocoList, converterSettings); + + [Benchmark] + public List DeserializePocoWithConverters() => + JsonConvert.DeserializeObject>(pocoJson, converterSettings); + + [Benchmark] + public string SerializeDictionary() => + JsonConvert.SerializeObject(dictionary); + + [Benchmark] + public string SerializeSnakeCaseDictionaryKeys() => + JsonConvert.SerializeObject(snakeDictionary, snakeSettings); + + [Benchmark] + public string SerializeUriProperties() => + JsonConvert.SerializeObject(uriHolders); + + [Benchmark] + public DayOfWeek[] DeserializeEnumsFromIntegers() => + JsonConvert.DeserializeObject(enumJson); + + [Benchmark] + public string SerializeEnumsAsNames() => + JsonConvert.SerializeObject(enumArray, new StringEnumConverter()); + + public class EnginePoco + { + public string Name { get; set; } + public string Description { get; set; } + public int Count { get; set; } + public double Value { get; set; } + public decimal Ratio { get; set; } + public bool Enabled { get; set; } + } + + public class UriHolder + { + public Uri Url { get; set; } + } + + class NoOpConverter1 : JsonConverter + { + public override bool CanConvert(Type type) => false; + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => throw new NotSupportedException(); + public override object ReadJson(JsonReader reader, Type type, object existingValue, JsonSerializer serializer) => throw new NotSupportedException(); + } + + class NoOpConverter2 : NoOpConverter1; + + class NoOpConverter3 : NoOpConverter1; +} diff --git a/src/ArgonTests/Benchmarks/WriterBenchmarks.cs b/src/ArgonTests/Benchmarks/WriterBenchmarks.cs new file mode 100644 index 000000000..deb0a67a0 --- /dev/null +++ b/src/ArgonTests/Benchmarks/WriterBenchmarks.cs @@ -0,0 +1,134 @@ +// 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 WriterBenchmarks +{ + Guid[] guids; + TimeSpan[] timeSpans; + double[] doubles; + string[] escapyStrings; + string[] cleanStrings; + + [GlobalSetup] + public void Setup() + { + guids = Enumerable.Range(0, 100).Select(_ => Guid.NewGuid()).ToArray(); + timeSpans = Enumerable.Range(0, 100).Select(i => TimeSpan.FromMinutes(i * 7.5)).ToArray(); + doubles = Enumerable.Range(0, 100).Select(i => i * 1.37).ToArray(); + escapyStrings = Enumerable.Range(0, 50).Select(i => $"line1\nline2 \"quoted{i}\" and\ttabs plus some longer tail text to scan {i}").ToArray(); + cleanStrings = Enumerable.Range(0, 50).Select(i => $"a perfectly clean string with no escapable characters at all number {i} padded padded padded").ToArray(); + } + + [Benchmark] + public string WritePropertyNames() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartObject(); + for (var i = 0; i < 20; i++) + { + writer.WritePropertyName("someProperty" + (i % 4)); + writer.WriteValue(1); + } + + writer.WriteEndObject(); + } + + return stringWriter.ToString(); + } + + [Benchmark] + public string WriteDoubles() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartArray(); + foreach (var value in doubles) + { + writer.WriteValue(value); + } + + writer.WriteEndArray(); + } + + return stringWriter.ToString(); + } + + [Benchmark] + public string WriteGuids() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartArray(); + foreach (var value in guids) + { + writer.WriteValue(value); + } + + writer.WriteEndArray(); + } + + return stringWriter.ToString(); + } + + [Benchmark] + public string WriteTimeSpans() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartArray(); + foreach (var value in timeSpans) + { + writer.WriteValue(value); + } + + writer.WriteEndArray(); + } + + return stringWriter.ToString(); + } + + [Benchmark] + public string WriteEscapedStrings() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartArray(); + foreach (var value in escapyStrings) + { + writer.WriteValue(value); + } + + writer.WriteEndArray(); + } + + return stringWriter.ToString(); + } + + [Benchmark] + public string WriteCleanStrings() + { + var stringWriter = new StringWriter(); + using (var writer = new JsonTextWriter(stringWriter)) + { + writer.WriteStartArray(); + foreach (var value in cleanStrings) + { + writer.WriteValue(value); + } + + writer.WriteEndArray(); + } + + return stringWriter.ToString(); + } +} diff --git a/src/Benchmark.Tests/Program.cs b/src/Benchmark.Tests/Program.cs index b27a41d99..3c1133570 100644 --- a/src/Benchmark.Tests/Program.cs +++ b/src/Benchmark.Tests/Program.cs @@ -20,7 +20,13 @@ public static void Main(string[] args) typeof(WriteBase64Benchmark), typeof(SplitFullyQualifiedTypeNameBench), typeof(PropertyOrderBenchmark), - typeof(DictionaryWrapperKeysBenchmark) + typeof(DictionaryWrapperKeysBenchmark), + typeof(SerializerEngineBenchmarks), + typeof(ReaderBenchmarks), + typeof(WriterBenchmarks), + typeof(LinqBenchmarks), + typeof(CreatorDeserializeBenchmark), + typeof(SatelliteBenchmarks) ]); if (args.Length == 0) {