Skip to content

Perf: faster date reads, pooled string output and less per value work - #416

Merged
SimonCropp merged 1 commit into
mainfrom
perf-per-value-work
Oct 6, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
perf-per-value-work

Conversation

@SimonCropp

Copy link
Copy Markdown
Member

Ten changes that each remove a cost paid per value read or written. No behaviour change is intended.

Changes

Reading

  • ISO dates (DateTimeUtils, JsonTextReader.ParseReadString): the shapes the writer produces are parsed straight from the reader's buffer. They were copied out into a string, parsed by TryParseExact, which interprets its format string for every value, and boxed twice. Anything else still goes to the framework.
  • Nullable members (JsonSerializerInternalReader.EnsureType): a value read for an int?, bool?, DateTime? and so on is used as it arrives. It was passed through Convert.ChangeType, which boxed the same value a second time.
  • Parameterized constructors (CreateObjectUsingCreatorWithParameters): the per property contexts are structs in a pooled array rather than a class each in a growing list.
  • Property lookup (PopulateObject, JsonPropertyCollection): the property after the previous match is tried first, by reference, before the name is hashed. A serializer writes properties in declaration order and the reader's name table returns the string the property was created with.
  • Enum names (EnumUtils.ParseEnum): a parsed name returns a shared box instead of calling Enum.ToObject.
  • Indentation (JsonTextReader): a run of spaces is stepped over in one loop rather than one trip around the token switch per space.

Writing

  • String output (JsonConvert.SerializeObject, JToken.ToString): the text is built in a pooled buffer, the new PooledStringWriter, rather than a StringWriter, whose StringBuilder allocates chunks as large as the result and then copies them.
  • Item contracts (JsonSerializerInternalWriter): the contract already held for a list item, dictionary value or member is used whenever the value is exactly the declared type, instead of a resolver cache lookup per value.
  • Converters on tokens (JValue.WriteTo, new ConverterListCache): which converter matched each type is remembered, for one WriteTo or for the whole run when the serializer writes the tokens, instead of asking every converter about every value.

LINQ to JSON

  • Small objects (JPropertyKeyedCollection, JContainer.ReadContentFrom): a JObject is searched as a list until it passes eight properties rather than building a dictionary on the first insert, and loading from a reader adds each token directly instead of through Add.

Benchmarks

ThroughputBenchmarks.cs has one class per change, registered in Benchmark.Tests. BenchmarkDotNet, net10.0, default job. Before is main, after is this branch, so rows overlap where a payload exercises more than one change.

Benchmark Before After Time Allocated
IsoDateRead.DateTimes 100.19 μs 15.79 μs −84% (6.3x) 66,960 → 20,960 B (−69%)
IsoDateRead.DateTimeOffsets 98.28 μs 17.64 μs −82% (5.6x) 85,120 → 33,120 B (−61%)
IsoDateRead.DateMembers 200.57 μs 54.16 μs −73% (3.7x) 116,992 → 38,592 B (−67%)
SerializeToString.SerializeObject 44.84 μs 42.38 μs −5% 76,032 → 43,144 B (−43%)
SerializeToString.SerializeObjectIndented 51.69 μs 49.78 μs −4% 109,312 → 60,352 B (−45%)
SerializeToString.TokenToString 36.56 μs 35.60 μs −3% 61,584 → 28,400 B (−54%)
ItemContract.Ints 22.47 μs 18.74 μs −17% 24,432 → 24,440 B
ItemContract.Strings 19.57 μs 15.96 μs −18% 440 → 448 B
ItemContract.DictionaryValues 14.34 μs 12.62 μs −12% 12,456 → 12,464 B
ItemContract.UnsealedObjects 48.08 μs 43.42 μs −10% 8,592 → 8,600 B
RecordRead.Records 133.61 μs 129.15 μs −3% 214,296 → 89,496 B (−58%)
NullableMemberRead.NullableMembers 137.66 μs 109.23 μs −21% 99,392 → 59,392 B (−40%)
JTokenConverterWrite.ToStringWithConverters 128.39 μs 36.10 μs −72% (3.6x) 61,590 → 28,595 B (−54%)
JTokenConverterWrite.SerializeWithConverters 135.17 μs 35.09 μs −74% (3.9x) 62,334 → 29,635 B (−52%)
JTokenConverterWrite.ToStringWithoutConverters 37.38 μs 34.08 μs −9% 61,584 → 28,400 B (−54%)
SmallJObject.Parse 178.44 μs 146.86 μs −18% 567,824 → 428,624 B (−25%)
SmallJObject.ParseWide 158.93 μs 143.79 μs −10% 498,680 → 452,600 B (−9%)
SmallJObject.DeepClone 171.74 μs 85.45 μs −50% (2.0x) 481,248 → 342,048 B (−29%)
SmallJObject.LookupByName 7.38 μs 7.42 μs +1% 40 → 40 B
IndentedRead.ReadIndented 59.61 μs 51.03 μs −14% 64,048 → 64,048 B
OrderedPropertyRead.DeclarationOrder 107.35 μs 96.67 μs −10% 59,296 → 59,296 B
OrderedPropertyRead.ReverseOrder 109.60 μs 108.02 μs −1% 59,296 → 59,296 B
EnumNameRead.EnumNames 94.27 μs 81.93 μs −13% 95,853 → 74,248 B (−23%)
  • Before and after were separate runs a few minutes apart on a laptop on battery, so single-digit time changes are approximate. The allocation column is exact.
  • LookupByName (lookups on objects that no longer have a dictionary), ReverseOrder (the property order guess always wrong) and ParseWide (12 property objects, past the threshold) are there to show no regression.
  • The 8 B added to the ItemContract rows is one extra field per serialize call, for the converter cache.

Notes for review

  • Dates: the fast path only accepts yyyy-MM-ddTHH:mm:ss, an optional fraction of one to seven digits, and nothing, Z or ±hh:mm. A value in the first or last day of the supported range that needs an offset applied is also left to the framework. Old and new were compared on 200,000 generated date strings across 12 entry points with no differences, in one time zone (AUS Eastern). That comparison is not part of this PR.
  • String output: converters called from SerializeObject now see a writer with CloseOutput = false, because the text is read back before the pooled buffer is returned. A buffer over a million chars is dropped rather than returned to the pool.
  • Converters on tokens: a JToken subclass that overrides WriteTo receives a wrapper list rather than the caller's list instance when converters are supplied.
  • Small objects: the threshold is eight properties. Keys and Values still build the dictionary on demand.

Testing

No new tests. The existing suite passes: 12,260 tests across net48, net8.0, net9.0, net10.0, net11.0 and F#.

Ten changes that each remove a cost paid per value read or written. No behaviour
change is intended. ThroughputBenchmarks covers every one of them and is registered
in Benchmark.Tests. The numbers quoted are BenchmarkDotNet on net10.0, the previous
commit against this one.

Reading
- ISO 8601 dates in the shapes the writer produces are parsed straight from the
  reader's buffer. They were copied out into a string, parsed by TryParseExact,
  which interprets its format string for every value, and boxed twice. Anything
  else still goes to the framework. List<DateTime> 6.3x faster, 69% less allocated.
- A value read for a Nullable<T> member is used as it arrives. It was passed
  through Convert.ChangeType, which boxed the same value a second time. 21% faster,
  40% less allocated.
- Objects built through a parameterized constructor keep their per property
  contexts as structs in a pooled array, rather than a class each in a growing
  list. Records: 58% less allocated.
- PopulateObject tries the property after the previous match first, by reference,
  before hashing the name. A serializer writes properties in declaration order and
  the reader's name table returns the string the property was created with. 10%
  faster on plain objects.
- A parsed enum name returns a shared box instead of calling Enum.ToObject. 13%
  faster, 23% less allocated.
- JsonTextReader steps over a run of spaces in one loop rather than one trip around
  the token switch per space. Indented input 14% faster.

Writing
- JsonConvert.SerializeObject and JToken.ToString build the text in a pooled buffer
  rather than a StringWriter, whose StringBuilder allocates chunks as large as the
  result and then copies them. 43-54% less allocated.
- The writer uses the contract it already holds for a list item, dictionary value
  or member whenever the value is exactly the declared type, instead of a resolver
  cache lookup per value. 10-18% faster.
- JValue.WriteTo remembers which converter matched each type instead of asking
  every converter about every value: for one WriteTo, or for the whole run when the
  serializer writes the tokens. 3.6x faster with twenty converters registered.

LINQ to JSON
- A JObject is searched as a list until it passes eight properties rather than
  building a dictionary on the first insert, and loading from a reader adds each
  token directly instead of through Add. Parse 18% faster with 25% less memory,
  DeepClone 2x faster.

Full suite green: 12260 tests across net48, net8.0, net9.0, net10.0, net11.0 and F#.
@SimonCropp SimonCropp added this to the 0.38.0 milestone Oct 6, 2026
@SimonCropp
SimonCropp merged commit 0d4d765 into main Oct 6, 2026
5 checks passed
@SimonCropp
SimonCropp deleted the perf-per-value-work branch October 6, 2026 11:53
This was referenced Oct 6, 2026
This was referenced Oct 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