Newtonsoft converter performance: fast-path resolution and JValue writes - #209
Merged
Conversation
…lookup Mirror the System.Text.Json converter optimizations in the Newtonsoft backend. GetType now resolves the first level with the current converter and only enters the converter-scan / list / cycle-protection walk for multi-level hierarchies, and GetTypeFromMapping converts string/int discriminators directly from the JToken instead of round-tripping through Newtonsoft's full ToObject reflection. Measured (BenchmarkDotNet, net10, DefaultJob): Single_Deserialize 2.54us -> 2.33us, Collection_Deserialize 10.21us -> 7.72us. All 147 Newtonsoft tests pass.
…nverters JToken.FromObject round-trips the discriminator through full reflection on every write. For the dominant string/int discriminators build the JValue directly instead, but only when no converter on the serializer handles that type: a custom int/string converter must not be silently bypassed, so it keeps the serializer-aware path. Adds tests on both backends covering the four branches: string/int without a converter (fast path) and with a custom converter (serializer-aware path). The System.Text.Json side always serializes through JsonSerializer, so its tests pin that custom converters are honored. Measured (BenchmarkDotNet, net10, DefaultJob): Single_Serialize 1.54us -> 1.31us, Collection_Serialize 5.51us -> 4.86us.
…t per type The base JsonSubtypes converter (attribute path) rebuilt the NullableDictionary from [KnownSubType] and the List from [KnownSubTypeWithProperty] on every deserialized object. The attributes themselves were cached; the derived mapping was not. Cache both per type, following the existing NET35/ConcurrentDictionary pattern of the attribute cache. The builder path is unaffected: it overrides GetSubTypeMapping to return its own mapping. Measured (attribute path, 100k objects): 4961 -> 4735 B/op.
The shared caches (attribute-derived mappings keyed by type in Newtonsoft, the converter list keyed by JsonSerializerOptions in STJ) must not conflate two converters registered for the same base type. Add tests on both backends that alternate between two profiles with different discriminator names and mappings, proving each resolves its own shape.
The Newtonsoft comparison table still showed the pre-optimization numbers. Update it to the measured values and document the optimizations applied, plus why the remaining cost is kept (the JObject/JTokenReader double parse is structural).
Serializing/deserializing the polymorphic base type itself exercises the converter's reflection-built base writer/reader fallback path, which was documented but never measured.
Each benchmark constructor now verifies that its scenario serializes to the expected discriminator and deserializes to the expected subtype, failing fast instead of silently measuring a broken path.
The string/int fast path in GetLookupValue must not bypass a converter registered on the serializer: like the discriminator write path, keep the serializer-aware ToObject when one applies, so the lookup matches the pre-optimization behavior. The converter scan uses an index-based loop, so the fast path stays allocation-free (measured allocations match the documented Newtonsoft numbers). Adds read-path tests with transforming int/string converters pinning that behavior on the Newtonsoft suite.
Same single-object and collection scenarios as NewtonsoftBenchmarks, so the discriminator converter can be compared against Newtonsoft's built-in type-name handling on the same payload shape. Auto writes $type only on members declared abstract/interface/object (a root declared as the base type never gets $type), so the single scenario goes through a holder and the base type is abstract.
manuc66
changed the base branch from
feature/split/pr2-stj-converter-perf
to
masterAugust 16, 2026 19:39
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 freeto 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.
Problem
The Newtonsoft backend lagged the STJ converter on the same fast-path treatment: it still walked the multi-level hierarchy and resolved discriminators via ToObject reflection on every call.
Fix
Tests
Honest note(s)