Skip to content

perf: reduce allocations and redundant work across core, JsonPath, Da…#379

Merged
SimonCropp merged 1 commit into
mainfrom
perf-improvements
Jul 6, 2026
Merged

perf: reduce allocations and redundant work across core, JsonPath, Da…#379
SimonCropp merged 1 commit into
mainfrom
perf-improvements

Conversation

@SimonCropp

Copy link
Copy Markdown
Member

…taSets 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%)

…taSets 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<T> 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%)
@SimonCropp SimonCropp added this to the 0.35.0 milestone Jul 6, 2026
@SimonCropp
SimonCropp merged commit bf21e80 into main Jul 6, 2026
5 of 6 checks passed
@SimonCropp
SimonCropp deleted the perf-improvements branch July 6, 2026 11:33
This was referenced Jul 6, 2026
This was referenced Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant