Repository navigation
Perf: faster date reads, pooled string output and less per value work - #416
Merged
Merged
Conversation
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#.
This was referenced Oct 6, 2026
Merged
Merged
This was referenced Oct 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ten changes that each remove a cost paid per value read or written. No behaviour change is intended.
Changes
Reading
DateTimeUtils,JsonTextReader.ParseReadString): the shapes the writer produces are parsed straight from the reader's buffer. They were copied out into a string, parsed byTryParseExact, which interprets its format string for every value, and boxed twice. Anything else still goes to the framework.JsonSerializerInternalReader.EnsureType): a value read for anint?,bool?,DateTime?and so on is used as it arrives. It was passed throughConvert.ChangeType, which boxed the same value a second time.CreateObjectUsingCreatorWithParameters): the per property contexts are structs in a pooled array rather than a class each in a growing list.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.EnumUtils.ParseEnum): a parsed name returns a shared box instead of callingEnum.ToObject.JsonTextReader): a run of spaces is stepped over in one loop rather than one trip around the token switch per space.Writing
JsonConvert.SerializeObject,JToken.ToString): the text is built in a pooled buffer, the newPooledStringWriter, rather than aStringWriter, whoseStringBuilderallocates chunks as large as the result and then copies them.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.JValue.WriteTo, newConverterListCache): which converter matched each type is remembered, for oneWriteToor for the whole run when the serializer writes the tokens, instead of asking every converter about every value.LINQ to JSON
JPropertyKeyedCollection,JContainer.ReadContentFrom): aJObjectis 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 throughAdd.Benchmarks
ThroughputBenchmarks.cshas one class per change, registered inBenchmark.Tests. BenchmarkDotNet, net10.0, default job. Before ismain, after is this branch, so rows overlap where a payload exercises more than one change.LookupByName(lookups on objects that no longer have a dictionary),ReverseOrder(the property order guess always wrong) andParseWide(12 property objects, past the threshold) are there to show no regression.ItemContractrows is one extra field per serialize call, for the converter cache.Notes for review
yyyy-MM-ddTHH:mm:ss, an optional fraction of one to seven digits, and nothing,Zor±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.SerializeObjectnow see a writer withCloseOutput = 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.JTokensubclass that overridesWriteToreceives a wrapper list rather than the caller's list instance when converters are supplied.KeysandValuesstill 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#.