From 3c73cef11759d1392e6816524959ce4593bb76cd Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 00:40:01 +0200 Subject: [PATCH 01/11] Cache the converter list and fast-path single-level type resolution The GetType walk re-scanned serializer.Converters and allocated a List and a HashSet on every deserialized object, even for single-level hierarchies. Cache the IJsonSubtypes list per JsonSerializerOptions (System.Text.Json freezes options on first use) and resolve the first level without allocating; only the nested multi-level walk keeps its cycle-protection set. Also compare string/int discriminators directly against the mapping instead of round-tripping through GetRawText() + JsonSerializer.Deserialize. Measured (BenchmarkDotNet, net10, DefaultJob): Converter_Deserialize 1.925us / 1000 B before, 1.672us / 648 B after. All 190 STJ tests still pass. --- JsonSubTypes.Text.Json/JsonSubtypes.cs | 68 +++++++++++++++++++++----- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index 870c211..f1cacb0 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -131,6 +132,13 @@ private static readonly ConcurrentDictionary AttributeResolverCache = new(); + // The converter resolution walk scans serializer.Converters for IJsonSubtypes + // instances. The result is stable for the lifetime of the options (System.Text.Json + // freezes JsonSerializerOptions on first use), so cache it instead of re-scanning + // and re-allocating a list on every deserialized object. + private static readonly ConditionalWeakTable + OptionsConverterCache = new(); + protected readonly string? JsonDiscriminatorPropertyName; private readonly NullableDictionary? _subTypeMapping; @@ -574,20 +582,35 @@ Type IJsonSubtypes.GetType(JsonDocument jObject, Type parentType, JsonSerializer private Type GetType(JsonDocument jObject, Type parentType, JsonSerializerOptions serializer) { + IJsonSubtypes[] converters = OptionsConverterCache.GetValue(serializer, static s => + [.. s.Converters.OfType()]); + Type targetType = parentType; - IJsonSubtypes? lastTypeResolver = null; - List converters = []; - foreach (JsonConverter converter in serializer.Converters) + IJsonSubtypes? currentTypeResolver = GetTypeResolver(targetType.GetTypeInfo(), converters); + if (currentTypeResolver == null) { - if (converter is IJsonSubtypes jsonSubtypes) - { - converters.Add(jsonSubtypes); - } + return targetType; } - IJsonSubtypes? currentTypeResolver = GetTypeResolver(targetType.GetTypeInfo(), converters); - HashSet visitedTypes = [targetType]; + targetType = currentTypeResolver.GetType(jObject, targetType, serializer); + if (targetType == parentType) + { + return targetType; + } + + // Single-level resolution is the common case: only allocate the nested + // walk (and its cycle-protection set) when the resolved type carries its + // own resolver, i.e. for multi-level hierarchies. + IJsonSubtypes? nestedResolver = GetTypeResolver(targetType.GetTypeInfo(), + converters.Where(c => c != currentTypeResolver)); + if (nestedResolver == null) + { + return targetType; + } + IJsonSubtypes lastTypeResolver = currentTypeResolver; + HashSet visitedTypes = [parentType, targetType]; + currentTypeResolver = nestedResolver; while (currentTypeResolver != null && currentTypeResolver != lastTypeResolver) { targetType = currentTypeResolver.GetType(jObject, targetType, serializer); @@ -597,8 +620,8 @@ private Type GetType(JsonDocument jObject, Type parentType, JsonSerializerOption } lastTypeResolver = currentTypeResolver; - converters = [.. converters.Where(c => c != currentTypeResolver)]; - currentTypeResolver = GetTypeResolver(targetType.GetTypeInfo(), converters); + currentTypeResolver = GetTypeResolver(targetType.GetTypeInfo(), + converters.Where(c => c != currentTypeResolver)); } return targetType; @@ -821,6 +844,29 @@ private static bool TryGetProperty(JsonElement obj, string name, JsonSerializerO object? key = typeMapping.NotNullKeys().FirstOrDefault(); if (key != null) { + // Fast path: for the dominant string/int mappings, compare the token directly + // instead of round-tripping through GetRawText() + JsonSerializer.Deserialize. + if (key is string && discriminatorToken.ValueKind == JsonValueKind.String) + { + string? stringValue = discriminatorToken.GetString(); + if (stringValue != null && typeMapping.TryGetValue(stringValue, out Type? stringTarget)) + { + return stringTarget; + } + + return null; + } + + if (key is int && discriminatorToken.TryGetInt32(out int intValue)) + { + if (typeMapping.TryGetValue(intValue, out Type? intTarget)) + { + return intTarget; + } + + return null; + } + Type targetLookupValueType = key.GetType(); object? lookupValue; try From 00c84ac22521e6e4886e715661018bc23d278047 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 00:40:16 +0200 Subject: [PATCH 02/11] Run the benchmarks on net10 and fix the NativeAOT toolchain build Move the benchmark project to net10.0 so the JIT and NativeAOT jobs measure the same runtime, and drop PublishAot from the host build: it disabled reflection for the whole process, making the reflection-based converter and resolver benchmarks unavailable. Use the Net10_0 NativeAOT preset instead of CreateBuilder().UseNuGet(), which required an explicit TargetFrameworkMoniker in this BenchmarkDotNet version. --- JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj | 8 ++++---- JsonSubTypes.Benchmarks/Program.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj b/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj index a55dbb4..a94af30 100644 --- a/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj +++ b/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj @@ -1,13 +1,13 @@ Exe - net8.0 + net10.0 enable latest - true true - + $(NoWarn);IL2026;IL3050 diff --git a/JsonSubTypes.Benchmarks/Program.cs b/JsonSubTypes.Benchmarks/Program.cs index 2e488ad..f291991 100644 --- a/JsonSubTypes.Benchmarks/Program.cs +++ b/JsonSubTypes.Benchmarks/Program.cs @@ -17,7 +17,7 @@ public static void Main(string[] args) IConfig config = ManualConfig.Create(DefaultConfig.Instance) .AddJob(Job.Default) .AddJob(Job.Default - .WithToolchain(NativeAotToolchain.CreateBuilder().UseNuGet("8.0.28").ToToolchain()) + .WithToolchain(NativeAotToolchain.Net10_0) .WithId("NativeAOT")); BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); From 75b8b301b82627fd390465bd46f99dfff46337ac Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 00:40:25 +0200 Subject: [PATCH 03/11] Document the compile-time vs runtime hierarchy difference between the engines The feature table suggests the converter and the generator are equivalent, but the decisive difference is not speed: the generator reads its registrations from attributes at compile time and can only route types visible to the compilation, while the converter's Build() accepts runtime registrations and is the only engine for plugins and third-party types you cannot annotate. --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 96d445e..7052da5 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,16 @@ Only types assignable from the polymorphic base type can be resolved, but any su 2. **Resolver (`BuildResolver()`)** — the thin native bridge: simplest and fastest, but limited to the subset the native contract model can express. 3. **Generator (`JsonSubTypes.Text.Json.Aot`)** — a Roslyn source generator emitting compiled converters: the Native AOT answer, with routing compiled instead of reflected. +**The decisive difference is not speed, it is when the hierarchy is known:** + +| | Converter (`Build()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | +| :--- | :--- | :--- | +| Subtypes known at **compile time** (attributes on your own types) | ✅ | ✅ | +| Subtypes known only at **runtime** (plugins, loaded assemblies, config) | ✅ | ✅ (via `RegisterDynamicSubtype` / resolver hooks) | +| Subtypes in **third-party assemblies** you cannot annotate | ✅ (builder, no attribute needed) | ❌ (generator only sees the source-gen context) | + +The generator reads its registrations from `[JsonSubTypesAotConverter]`/`[KnownSubType]`-style **attributes at compile time** (`JsonSubTypesGenerator.cs`). It can only route types visible to the compilation it runs in. The converter's `Build()` accepts a **runtime** registration through the builder, so it is the only engine that can handle hierarchies whose subtypes are discovered at runtime — plugins, assemblies loaded dynamically, or types you do not own. The generator is the better fit when the hierarchy is fixed and known at build time, and the only engine compatible with trimming/Native AOT. + ### Converter known scope & fallback path To preserve full compatibility with advanced features while delegating object serialization to `System.Text.Json`, the converter isolates base-type serialization to a narrow path (when serializing the base type directly or reading an unregistered fallback type): From cf3f5f209214af7f5c9dff4ef43005275bfd8012 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 00:53:21 +0200 Subject: [PATCH 04/11] Deserialize from the parsed JsonElement instead of re-reading raw bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadObject parsed the JSON into a JsonDocument to find the discriminator, then DeserializerHelper re-serialized the payload from the raw Utf8JsonReader — a second full materialization the generator does not do. Deserialize the resolved subtype from the already-parsed RootElement instead, matching the generator's path, and drop the now-unused DeserializerHelper. Measured (BenchmarkDotNet, net10, DefaultJob): Converter_Deserialize 1.672us before, 1.499us after. All STJ and AOT parity tests still pass. --- JsonSubTypes.Text.Json/DeserializerHelper.cs | 32 -------------------- JsonSubTypes.Text.Json/JsonSubtypes.cs | 2 +- 2 files changed, 1 insertion(+), 33 deletions(-) delete mode 100644 JsonSubTypes.Text.Json/DeserializerHelper.cs diff --git a/JsonSubTypes.Text.Json/DeserializerHelper.cs b/JsonSubTypes.Text.Json/DeserializerHelper.cs deleted file mode 100644 index 9a9789a..0000000 --- a/JsonSubTypes.Text.Json/DeserializerHelper.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Text.Json; - -namespace JsonSubTypes.Text.Json; - -internal interface ISimpleMethod -{ - object DeserializeSimple(ref Utf8JsonReader reader, JsonSerializerOptions options); -} - -internal class DeserializerHelper : ISimpleMethod -{ - private static readonly ConcurrentDictionary HelperCache = new(); - - private T Deserialize(ref Utf8JsonReader reader, JsonSerializerOptions options) - { - return JsonSerializer.Deserialize(ref reader, options)!; - } - - public object DeserializeSimple(ref Utf8JsonReader reader, JsonSerializerOptions options) - { - return Deserialize(ref reader, options)!; - } - - internal static T Deserialize(ref Utf8JsonReader reader, Type targetType, JsonSerializerOptions options) - { - ISimpleMethod genericConverterInstance = HelperCache.GetOrAdd(targetType, static type => - (ISimpleMethod)Activator.CreateInstance(typeof(DeserializerHelper<>).MakeGenericType(type))!); - return (T)genericConverterInstance.DeserializeSimple(ref reader, options); - } -} \ No newline at end of file diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index f1cacb0..ee8755e 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -562,7 +562,7 @@ private static IList CreateCompatibleList(Type targetContainerType, Type element return ReadPlainObject(ref readerAtStart, targetType, serializer); } - return (T?)DeserializerHelper.Deserialize(ref readerAtStart, targetType, serializer); + return (T?)JsonSerializer.Deserialize(jObject.RootElement, targetType, serializer); } Type IJsonSubtypes.GetType(JsonDocument jObject, Type parentType, JsonSerializerOptions jsonSerializerOptions) From 2593a0b7ded9f5b52d6de9a677bc0268417b11be Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 01:03:45 +0200 Subject: [PATCH 05/11] Avoid the UTF-16 string round-trip on the discriminator write path WriteObjectWithDiscriminator received the payload as a string, produced by JsonSerializer.Serialize (UTF-16) or Encoding.UTF8.GetString, then re-parsed it. Serialize straight into an ArrayBufferWriter and parse the UTF-8 bytes directly, matching how System.Text.Json handles bytes internally and skipping two encodings conversions. Adds a parity test serializing a subtype with non-ASCII characters (accent, snowman, surrogate-pair emoji) asserting the exact escaped form, so a regression in the JsonDocument write path is caught. Measured (BenchmarkDotNet, net10, DefaultJob): Converter_Serialize 1.220us / 664 B before, 1.142us / 856 B after. All STJ and AOT parity tests still pass. --- .../EngineParityTests.cs | 21 +++++++++++++++++++ JsonSubTypes.Text.Json/JsonSubtypes.cs | 21 +++++++++++-------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs b/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs index 566f4bc..dcfefa2 100644 --- a/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs +++ b/JsonSubTypes.Text.Json.Aot.Tests/EngineParityTests.cs @@ -74,6 +74,27 @@ public void SerializeThenDeserialize() Assert.AreEqual(root, back); } + [Test] + public void SerializeNonAsciiCharacters() + { + Requires(ParityCapabilities.ValueDiscriminator | ParityCapabilities.DiscriminatorNameCollision); + var root = new Root + { + Content = new SubC + { + Name = "caf\u00e9 \u2603 \U0001F4A5" + } + }; + + string str = JsonSerializer.Serialize(root, CreateOptions()); + var back = JsonSerializer.Deserialize(str, CreateOptions()); + + Assert.AreEqual(root, back); + // System.Text.Json escapes non-ASCII by default; assert the exact escaped form so a + // regression in the discriminator write path (round-trip through JsonDocument) is caught. + StringAssert.Contains("\"Name\":\"caf\\u00E9 \\u2603 \\uD83D\\uDCA5\"", str); + } + [Test] public void DeserializeSubType() { diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index ee8755e..ec8d035 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; @@ -7,7 +8,6 @@ using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -211,8 +211,12 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions { if (_serializeDiscriminatorProperty && TryGetDiscriminatorValue(runtimeType, out object? discriminatorValue)) { - string json = JsonSerializer.Serialize(value, runtimeType, serializer); - WriteObjectWithDiscriminator(writer, json, discriminatorValue, serializer); + ArrayBufferWriter buffer = new(); + using (Utf8JsonWriter bufferWriter = new(buffer)) + { + JsonSerializer.Serialize(bufferWriter, value, runtimeType, serializer); + } + WriteObjectWithDiscriminator(writer, buffer.WrittenMemory, discriminatorValue, serializer); return; } @@ -229,13 +233,12 @@ public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions { Action baseWriter = BaseTypeWriterCache.GetOrAdd(typeof(T), static type => BuildBaseTypeWriter(type)); - using MemoryStream stream = new(); - using (Utf8JsonWriter bufferWriter = new(stream)) + ArrayBufferWriter buffer = new(); + using (Utf8JsonWriter bufferWriter = new(buffer)) { baseWriter(bufferWriter, value, serializer); } - WriteObjectWithDiscriminator(writer, Encoding.UTF8.GetString(stream.ToArray()), baseDiscriminatorValue, - serializer); + WriteObjectWithDiscriminator(writer, buffer.WrittenMemory, baseDiscriminatorValue, serializer); return; } @@ -284,8 +287,8 @@ private static void ThrowImpossibleToSerialize(Type runtimeType) $"Impossible to serialize type: {runtimeType.FullName} because there is no registered mapping for the discriminator property"); } - private void WriteObjectWithDiscriminator(Utf8JsonWriter writer, string json, object? discriminatorValue, - JsonSerializerOptions serializer) + private void WriteObjectWithDiscriminator(Utf8JsonWriter writer, ReadOnlyMemory json, + object? discriminatorValue, JsonSerializerOptions serializer) { string discriminatorName = JsonDiscriminatorPropertyName!; if (serializer.PropertyNamingPolicy != null) From 74b9a9ec21d62b5fe9b2ab18f9ca89dc351b8ed7 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 08:34:56 +0200 Subject: [PATCH 06/11] Benchmark more use-cases and add a Newtonsoft.Json baseline The benchmark suite measured only a single flat object. Add benchmarks for the use-cases that matter in practice: collections of polymorphic objects, nested multi-level hierarchies, and property-presence discrimination, plus a Newtonsoft.Json baseline mirroring the single-object and collection scenarios. Nested-hierarchy serialization is benchmarked only on the generated engine: the converter falls back to the plain runtime-type contract there (documented in the README), so a converter write benchmark would not measure discriminator injection. Disambiguate JsonSubtypesConverterBuilder between the JsonSubTypes (Newtonsoft) and JsonSubTypes.Text.Json packages with explicit aliases. --- .../CollectionBenchmarks.cs | 119 +++++++++++++++ .../JsonSubTypes.Benchmarks.csproj | 1 + .../NestedHierarchyBenchmarks.cs | 142 ++++++++++++++++++ .../NewtonsoftBenchmarks.cs | 59 ++++++++ JsonSubTypes.Benchmarks/Program.cs | 5 +- .../PropertyPresenceBenchmarks.cs | 95 ++++++++++++ README.md | 53 ++++++- 7 files changed, 464 insertions(+), 10 deletions(-) create mode 100644 JsonSubTypes.Benchmarks/CollectionBenchmarks.cs create mode 100644 JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs create mode 100644 JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs create mode 100644 JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs diff --git a/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs b/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs new file mode 100644 index 0000000..fa7e98d --- /dev/null +++ b/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using BenchmarkDotNet.Attributes; +using JsonSubTypes.Text.Json; +using JsonSubTypes.Text.Json.Aot.Generated; +using StjBuilder = JsonSubTypes.Text.Json.JsonSubtypesConverterBuilder; + +namespace JsonSubTypes.Benchmarks +{ + [MemoryDiagnoser] + public class CollectionBenchmarks + { + private readonly JsonSerializerOptions? _converterOptions; + private readonly JsonSerializerOptions? _resolverOptions; + private readonly JsonSerializerOptions _generatedOptions = new JsonSerializerOptions + { + TypeInfoResolver = ColContext.Default, + Converters = { JsonSubTypesAotConverters.ColAnimal } + }; + + private readonly List _convAnimals; + private readonly List _resAnimals; + private readonly List _generatedAnimals; + + private readonly string? _converterJson; + private readonly string? _resolverJson; + private readonly string _generatedJson; + + public CollectionBenchmarks() + { + _convAnimals = new List + { + new ColConvCat { Age = 3, Lives = 9 }, + new ColConvDog { Age = 5, CanHunt = true }, + new ColConvCat { Age = 7, Lives = 7 }, + new ColConvDog { Age = 1, CanHunt = false } + }; + _resAnimals = new List + { + new ColResCat { Age = 3, Lives = 9 }, + new ColResDog { Age = 5, CanHunt = true }, + new ColResCat { Age = 7, Lives = 7 }, + new ColResDog { Age = 1, CanHunt = false } + }; + _generatedAnimals = new List + { + new ColCat { Age = 3, Lives = 9 }, + new ColDog { Age = 5, CanHunt = true }, + new ColCat { Age = 7, Lives = 7 }, + new ColDog { Age = 1, CanHunt = false } + }; + + if (JsonSerializer.IsReflectionEnabledByDefault) + { + _converterOptions = new JsonSerializerOptions(); + _converterOptions.Converters.Add(StjBuilder.Of("type") + .RegisterSubtype("cat") + .RegisterSubtype("dog") + .SerializeDiscriminatorProperty() + .Build()); + _resolverOptions = new JsonSerializerOptions + { + TypeInfoResolver = StjBuilder.Of("type") + .RegisterSubtype("cat") + .RegisterSubtype("dog") + .SerializeDiscriminatorProperty() + .BuildResolver() + }; + + _converterJson = JsonSerializer.Serialize(_convAnimals, _converterOptions); + _resolverJson = JsonSerializer.Serialize(_resAnimals, _resolverOptions); + } + + _generatedJson = JsonSerializer.Serialize(_generatedAnimals, _generatedOptions); + } + + [Benchmark] + public string Converter_Serialize() => JsonSerializer.Serialize(_convAnimals, _converterOptions!); + + [Benchmark] + public string Resolver_Serialize() => JsonSerializer.Serialize(_resAnimals, _resolverOptions!); + + [Benchmark] + public string Generated_Serialize() => JsonSerializer.Serialize(_generatedAnimals, _generatedOptions); + + [Benchmark] + public List? Converter_Deserialize() => JsonSerializer.Deserialize>(_converterJson!, _converterOptions!); + + [Benchmark] + public List? Resolver_Deserialize() => JsonSerializer.Deserialize>(_resolverJson!, _resolverOptions!); + + [Benchmark] + public List? Generated_Deserialize() => JsonSerializer.Deserialize>(_generatedJson, _generatedOptions); + } + + public class ColConvAnimal { public int Age { get; set; } } + public class ColConvCat : ColConvAnimal { public int Lives { get; set; } } + public class ColConvDog : ColConvAnimal { public bool CanHunt { get; set; } } + + public class ColResAnimal { public int Age { get; set; } } + public class ColResCat : ColResAnimal { public int Lives { get; set; } } + public class ColResDog : ColResAnimal { public bool CanHunt { get; set; } } + + [JsonSubTypesAotConverter("type")] + [KnownSubType(typeof(ColCat), "cat")] + [KnownSubType(typeof(ColDog), "dog")] + public class ColAnimal { public int Age { get; set; } } + public class ColCat : ColAnimal { public int Lives { get; set; } } + public class ColDog : ColAnimal { public bool CanHunt { get; set; } } + + [JsonSerializable(typeof(ColAnimal))] + [JsonSerializable(typeof(ColCat))] + [JsonSerializable(typeof(ColDog))] + [JsonSerializable(typeof(List))] + public partial class ColContext : JsonSerializerContext + { + } +} diff --git a/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj b/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj index a94af30..c7d54fb 100644 --- a/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj +++ b/JsonSubTypes.Benchmarks/JsonSubTypes.Benchmarks.csproj @@ -18,5 +18,6 @@ + diff --git a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs new file mode 100644 index 0000000..ecc9b93 --- /dev/null +++ b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs @@ -0,0 +1,142 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BenchmarkDotNet.Attributes; +using JsonSubTypes.Text.Json; +using JsonSubTypes.Text.Json.Aot.Generated; +using StjBuilder = JsonSubTypes.Text.Json.JsonSubtypesConverterBuilder; + +namespace JsonSubTypes.Benchmarks +{ + [MemoryDiagnoser] + public class NestedHierarchyBenchmarks + { + private readonly JsonSerializerOptions? _converterOptions; + private readonly JsonSerializerOptions _generatedOptions = new JsonSerializerOptions + { + TypeInfoResolver = NestedContext.Default, + Converters = { JsonSubTypesAotConverters.NestedPayload } + }; + + private readonly ConvRun _convRun = new ConvRun(); + private readonly NestedRun _nestedRun = new NestedRun(); + + private readonly string? _converterJson; + private readonly string _generatedJson; + + public NestedHierarchyBenchmarks() + { + if (JsonSerializer.IsReflectionEnabledByDefault) + { + _converterOptions = new JsonSerializerOptions(); + _converterOptions.Converters.Add(StjBuilder + .Of(typeof(ConvPayload), "$PayloadKind") + .RegisterSubtype(typeof(ConvGame), PayloadDiscriminator.GAME) + .RegisterSubtype(typeof(ConvCom), PayloadDiscriminator.COM) + .Build()); + _converterOptions.Converters.Add(StjBuilder + .Of(typeof(ConvGame), "$GameKind") + .RegisterSubtype(typeof(ConvRun), GameDiscriminator.RUN) + .RegisterSubtype(typeof(ConvWalk), GameDiscriminator.WALK) + .Build()); + + _converterJson = JsonSerializer.Serialize(_convRun, _converterOptions); + } + + _generatedJson = JsonSerializer.Serialize(_nestedRun, _generatedOptions); + } + + [Benchmark] + public string Generated_Serialize() => JsonSerializer.Serialize(_nestedRun, _generatedOptions); + + [Benchmark] + public ConvPayload? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + + [Benchmark] + public NestedPayload? Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); + } + + public enum PayloadDiscriminator + { + COM = 0, + GAME = 1 + } + + public enum GameDiscriminator + { + RUN = 0, + WALK = 1 + } + + public class ConvPayload + { + [JsonPropertyName("$PayloadKind")] + public PayloadDiscriminator PayloadKind { get; set; } = PayloadDiscriminator.GAME; + } + + public class ConvGame : ConvPayload + { + [JsonPropertyName("$GameKind")] + public GameDiscriminator GameKind { get; set; } = GameDiscriminator.WALK; + } + + public class ConvRun : ConvGame + { + public ConvRun() + { + PayloadKind = PayloadDiscriminator.GAME; + GameKind = GameDiscriminator.RUN; + } + } + + public class ConvWalk : ConvGame + { + } + + public class ConvCom : ConvPayload + { + } + + [JsonSubTypesAotConverter("$PayloadKind")] + [KnownSubType(typeof(NestedGame), PayloadDiscriminator.GAME)] + [KnownSubType(typeof(NestedCom), PayloadDiscriminator.COM)] + public class NestedPayload + { + [JsonPropertyName("$PayloadKind")] + public PayloadDiscriminator PayloadKind { get; set; } = PayloadDiscriminator.GAME; + } + + [JsonSubTypesAotConverter("$GameKind")] + [KnownSubType(typeof(NestedRun), GameDiscriminator.RUN)] + [KnownSubType(typeof(NestedWalk), GameDiscriminator.WALK)] + public class NestedGame : NestedPayload + { + [JsonPropertyName("$GameKind")] + public GameDiscriminator GameKind { get; set; } = GameDiscriminator.WALK; + } + + public class NestedRun : NestedGame + { + public NestedRun() + { + PayloadKind = PayloadDiscriminator.GAME; + GameKind = GameDiscriminator.RUN; + } + } + + public class NestedWalk : NestedGame + { + } + + public class NestedCom : NestedPayload + { + } + + [JsonSerializable(typeof(NestedPayload))] + [JsonSerializable(typeof(NestedGame))] + [JsonSerializable(typeof(NestedRun))] + [JsonSerializable(typeof(NestedWalk))] + [JsonSerializable(typeof(NestedCom))] + public partial class NestedContext : JsonSerializerContext + { + } +} diff --git a/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs b/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs new file mode 100644 index 0000000..d3e2e8f --- /dev/null +++ b/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using BenchmarkDotNet.Attributes; +using Newtonsoft.Json; + +namespace JsonSubTypes.Benchmarks +{ + // Newtonsoft.Json baseline: the original package. These benchmarks mirror the single-object + // and collection scenarios of the System.Text.Json benchmarks so the two packages can be + // compared. Newtonsoft runs on reflection only, so these benchmarks report NA under the + // NativeAOT job (like the reflection-based STJ engines). + [MemoryDiagnoser] + public class NewtonsoftBenchmarks + { + private readonly JsonSerializerSettings _settings = new JsonSerializerSettings(); + + private readonly NwAnimal _animal = new NwCat { Age = 3, Lives = 9 }; + private readonly List _animals; + + private readonly string _singleJson; + private readonly string _collectionJson; + + public NewtonsoftBenchmarks() + { + _settings.Converters.Add(JsonSubTypes.JsonSubtypesConverterBuilder + .Of("type") + .RegisterSubtype("cat") + .RegisterSubtype("dog") + .SerializeDiscriminatorProperty() + .Build()); + + _animals = new List + { + new NwCat { Age = 3, Lives = 9 }, + new NwDog { Age = 5, CanHunt = true }, + new NwCat { Age = 7, Lives = 7 }, + new NwDog { Age = 1, CanHunt = false } + }; + + _singleJson = JsonConvert.SerializeObject(_animal, _settings); + _collectionJson = JsonConvert.SerializeObject(_animals, _settings); + } + + [Benchmark] + public string Single_Serialize() => JsonConvert.SerializeObject(_animal, _settings); + + [Benchmark] + public NwAnimal? Single_Deserialize() => JsonConvert.DeserializeObject(_singleJson, _settings); + + [Benchmark] + public string Collection_Serialize() => JsonConvert.SerializeObject(_animals, _settings); + + [Benchmark] + public List? Collection_Deserialize() => JsonConvert.DeserializeObject>(_collectionJson, _settings); + } + + public class NwAnimal { public int Age { get; set; } } + public class NwCat : NwAnimal { public int Lives { get; set; } } + public class NwDog : NwAnimal { public bool CanHunt { get; set; } } +} diff --git a/JsonSubTypes.Benchmarks/Program.cs b/JsonSubTypes.Benchmarks/Program.cs index f291991..478ca30 100644 --- a/JsonSubTypes.Benchmarks/Program.cs +++ b/JsonSubTypes.Benchmarks/Program.cs @@ -7,6 +7,7 @@ using BenchmarkDotNet.Toolchains.NativeAot; using JsonSubTypes.Text.Json.Aot.Generated; using JsonSubTypes.Text.Json; +using StjBuilder = JsonSubTypes.Text.Json.JsonSubtypesConverterBuilder; namespace JsonSubTypes.Benchmarks { @@ -48,14 +49,14 @@ public PolymorphismBenchmarks() if (JsonSerializer.IsReflectionEnabledByDefault) { _converterOptions = new JsonSerializerOptions(); - _converterOptions.Converters.Add(JsonSubtypesConverterBuilder.Of("type") + _converterOptions.Converters.Add(StjBuilder.Of("type") .RegisterSubtype("cat") .RegisterSubtype("dog") .SerializeDiscriminatorProperty() .Build()); _resolverOptions = new JsonSerializerOptions { - TypeInfoResolver = JsonSubtypesConverterBuilder.Of("type") + TypeInfoResolver = StjBuilder.Of("type") .RegisterSubtype("cat") .RegisterSubtype("dog") .SerializeDiscriminatorProperty() diff --git a/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs b/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs new file mode 100644 index 0000000..abbdd8f --- /dev/null +++ b/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs @@ -0,0 +1,95 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using BenchmarkDotNet.Attributes; +using JsonSubTypes.Text.Json; +using JsonSubTypes.Text.Json.Aot.Generated; +using StjWithPropertyBuilder = JsonSubTypes.Text.Json.JsonSubtypesWithPropertyConverterBuilder; + +namespace JsonSubTypes.Benchmarks +{ + [MemoryDiagnoser] + public class PropertyPresenceBenchmarks + { + private readonly JsonSerializerOptions? _converterOptions; + private readonly JsonSerializerOptions _generatedOptions = new JsonSerializerOptions + { + TypeInfoResolver = PresenceContext.Default, + Converters = { JsonSubTypesAotConverters.PresencePerson } + }; + + private readonly ConvEmployee _convEmployee = new ConvEmployee { JobTitle = "Dev" }; + private readonly PresenceEmployee _presenceEmployee = new PresenceEmployee { JobTitle = "Dev" }; + + private readonly string? _converterJson; + private readonly string _generatedJson; + + public PropertyPresenceBenchmarks() + { + if (JsonSerializer.IsReflectionEnabledByDefault) + { + _converterOptions = new JsonSerializerOptions(); + _converterOptions.Converters.Add(StjWithPropertyBuilder + .Of(typeof(ConvPerson)) + .RegisterSubtypeWithProperty("JobTitle") + .RegisterSubtypeWithProperty("Skill") + .Build()); + + _converterJson = JsonSerializer.Serialize(_convEmployee, _converterOptions); + } + + _generatedJson = JsonSerializer.Serialize(_presenceEmployee, _generatedOptions); + } + + [Benchmark] + public string Converter_Serialize() => JsonSerializer.Serialize(_convEmployee, _converterOptions!); + + [Benchmark] + public string Generated_Serialize() => JsonSerializer.Serialize(_presenceEmployee, _generatedOptions); + + [Benchmark] + public ConvPerson? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + + [Benchmark] + public PresencePerson? Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); + } + + public class ConvPerson + { + public string? FirstName { get; set; } + } + + public class ConvEmployee : ConvPerson + { + public string? JobTitle { get; set; } + } + + public class ConvArtist : ConvPerson + { + public string? Skill { get; set; } + } + + [JsonSubTypesAotConverter] + [KnownSubTypeWithProperty(typeof(PresenceEmployee), "JobTitle")] + [KnownSubTypeWithProperty(typeof(PresenceArtist), "Skill")] + public class PresencePerson + { + public string? FirstName { get; set; } + } + + public class PresenceEmployee : PresencePerson + { + public string? JobTitle { get; set; } + } + + public class PresenceArtist : PresencePerson + { + public string? Skill { get; set; } + } + + [JsonSerializable(typeof(PresencePerson))] + [JsonSerializable(typeof(PresenceEmployee))] + [JsonSerializable(typeof(PresenceArtist))] + public partial class PresenceContext : JsonSerializerContext + { + } +} diff --git a/README.md b/README.md index 7052da5..845ce53 100644 --- a/README.md +++ b/README.md @@ -426,24 +426,61 @@ To preserve full compatibility with advanced features while delegating object se ### Performance (measured) -Measured with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, DefaultJob, .NET 8.0, one machine; serializing/deserializing a `Cat` declared as its `Animal` base). Numbers are machine-specific but reproducible by running that project. +Measured with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, `DefaultJob`, **.NET 10.0**, one run per scenario). The numbers below are from a single representative run on the machine listed below; they vary across machines and runs, but the benchmark project is self-contained and you can reproduce them: + +```bash +dotnet run -c Release --project JsonSubTypes.Benchmarks +``` + +**Machine** (what BenchmarkDotNet reported): Intel Core i7-4790 CPU 3.60 GHz (Haswell), 8 logical / 4 physical cores, Linux (Manjaro), .NET 10.0.9. + +The scenarios: a flat `Cat` declared as its `Animal` base ("single object"), a list of four mixed animals ("collection"), a two-level hierarchy (`Payload → Game → Run`, "nested"), and property-presence discrimination. The nested write scenario is measured on the generated engine only: the converter falls back to the plain runtime-type contract there and writes no discriminator, so its write number would not measure discriminator injection. + +**Single object** (JIT): | Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | | :--- | ---: | ---: | ---: | -| Serialize | 1.65–1.70 µs / 648 B | 0.40–0.41 µs / 400 B | 1.43–1.47 µs / 440 B | -| Deserialize | 2.63–2.71 µs / 1264 B | 0.51–0.55 µs / 56 B | 1.50–1.56 µs / 216 B | +| Serialize | 1.14 µs / 856 B | 0.33 µs / 400 B | 1.18 µs / 656 B | +| Deserialize | 1.50 µs / 648 B | 0.43 µs / 56 B | 0.98 µs / 152 B | + +**Collection of 4 objects** (JIT): -Reading: the resolver is the fastest (native streaming, no double parse). The generated converter beats the runtime converter on deserialization, and allocates ~6× less (compiled routing, no per-call converter scan). +| Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | +| :--- | ---: | ---: | ---: | +| Serialize | 4.16 µs / 3288 B | 0.99 µs / 624 B | 4.29 µs / 2600 B | +| Deserialize | 5.64 µs / 2744 B | 1.87 µs / 784 B | 4.54 µs / 696 B | -**Native AOT** (generated engine, measured with a BenchmarkDotNet NativeAOT job in the same benchmark project): +**Nested hierarchy and property presence** (JIT): -| Benchmark | DefaultJob (JIT) | Native AOT | +| Benchmark | Converter (`Build()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | | :--- | ---: | ---: | -| Generated_Serialize | 1.47–1.52 µs / 440 B | 1.63–1.74 µs / 440 B | -| Generated_Deserialize | 1.46–1.54 µs / 216 B | 1.74–1.82 µs / 216 B | +| Nested deserialize | 1.73 µs / 1152 B | 1.15 µs / 144 B | +| Nested serialize | — (no discriminator written) | 1.54 µs / 1016 B | +| Property-presence deserialize | 1.21 µs / 776 B | 1.00 µs / 312 B | +| Property-presence serialize | 0.27 µs / 96 B | 0.27 µs / 96 B | + +Reading: the resolver is the fastest (native streaming, no double parse, no `JsonDocument`). The generated converter beats the runtime converter on deserialization and allocates far less (compiled routing instead of per-call converter scans and type resolution). On serialization the converter and the generator are close; both write the discriminator by round-tripping the payload through a `JsonDocument`, which is also why deeply nested graphs need `MaxDepth` one level higher (see above). + +**Native AOT** (generated engine, BenchmarkDotNet NativeAOT job in the same project): + +| Benchmark | JIT | Native AOT | +| :--- | ---: | ---: | +| Generated_Serialize (single) | 1.18 µs / 656 B | 1.43 µs / 640 B | +| Generated_Deserialize (single) | 0.98 µs / 152 B | 1.29 µs / 152 B | In steady state, Native AOT is comparable to (slightly slower than) JIT for this workload. The real AOT advantage is trimming compatibility and startup time, not steady-state throughput. +**Newtonsoft.Json comparison** (JIT, same machine and scenarios): the original `JsonSubTypes` package, through `JsonConvert`. It is a different runtime (reflection-based, no `Utf8JsonWriter`), so treat these as an order-of-magnitude reference, not a like-for-like benchmark: + +| Benchmark | Newtonsoft (`JsonSubTypes`) | STJ Converter (`Build()`) | +| :--- | ---: | ---: | +| Single serialize | 1.54 µs / 3.07 KB | 1.14 µs / 856 B | +| Single deserialize | 2.54 µs / 5.26 KB | 1.50 µs / 648 B | +| Collection serialize (4) | 5.51 µs / 7.53 KB | 4.16 µs / 3288 B | +| Collection deserialize (4) | 10.21 µs / 12.96 KB | 5.64 µs / 2744 B | + +Newtonsoft is slower and allocates several times more on every scenario. The STJ converter is not a drop-in replacement at the API level, but if you are on .NET 8+ the `JsonSubTypes.Text.Json` package is the faster option for the same scenarios. + ### Decision matrix | Use case | Recommended | |---|---| From d68f01dde869b48ff5f271f789ae6b0034f01b85 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 08:35:18 +0200 Subject: [PATCH 07/11] Prefix benchmark method names per scenario so results are unambiguous --- JsonSubTypes.Benchmarks/CollectionBenchmarks.cs | 12 ++++++------ JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs | 6 +++--- .../PropertyPresenceBenchmarks.cs | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs b/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs index fa7e98d..d0d95bb 100644 --- a/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs @@ -76,22 +76,22 @@ public CollectionBenchmarks() } [Benchmark] - public string Converter_Serialize() => JsonSerializer.Serialize(_convAnimals, _converterOptions!); + public string Col_Converter_Serialize() => JsonSerializer.Serialize(_convAnimals, _converterOptions!); [Benchmark] - public string Resolver_Serialize() => JsonSerializer.Serialize(_resAnimals, _resolverOptions!); + public string Col_Resolver_Serialize() => JsonSerializer.Serialize(_resAnimals, _resolverOptions!); [Benchmark] - public string Generated_Serialize() => JsonSerializer.Serialize(_generatedAnimals, _generatedOptions); + public string Col_Generated_Serialize() => JsonSerializer.Serialize(_generatedAnimals, _generatedOptions); [Benchmark] - public List? Converter_Deserialize() => JsonSerializer.Deserialize>(_converterJson!, _converterOptions!); + public List? Col_Converter_Deserialize() => JsonSerializer.Deserialize>(_converterJson!, _converterOptions!); [Benchmark] - public List? Resolver_Deserialize() => JsonSerializer.Deserialize>(_resolverJson!, _resolverOptions!); + public List? Col_Resolver_Deserialize() => JsonSerializer.Deserialize>(_resolverJson!, _resolverOptions!); [Benchmark] - public List? Generated_Deserialize() => JsonSerializer.Deserialize>(_generatedJson, _generatedOptions); + public List? Col_Generated_Deserialize() => JsonSerializer.Deserialize>(_generatedJson, _generatedOptions); } public class ColConvAnimal { public int Age { get; set; } } diff --git a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs index ecc9b93..2ac3f84 100644 --- a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs @@ -46,13 +46,13 @@ public NestedHierarchyBenchmarks() } [Benchmark] - public string Generated_Serialize() => JsonSerializer.Serialize(_nestedRun, _generatedOptions); + public string Nested_Generated_Serialize() => JsonSerializer.Serialize(_nestedRun, _generatedOptions); [Benchmark] - public ConvPayload? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + public ConvPayload? Nested_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); [Benchmark] - public NestedPayload? Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); + public NestedPayload? Nested_Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); } public enum PayloadDiscriminator diff --git a/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs b/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs index abbdd8f..d44f088 100644 --- a/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs @@ -41,16 +41,16 @@ public PropertyPresenceBenchmarks() } [Benchmark] - public string Converter_Serialize() => JsonSerializer.Serialize(_convEmployee, _converterOptions!); + public string Pres_Converter_Serialize() => JsonSerializer.Serialize(_convEmployee, _converterOptions!); [Benchmark] - public string Generated_Serialize() => JsonSerializer.Serialize(_presenceEmployee, _generatedOptions); + public string Pres_Generated_Serialize() => JsonSerializer.Serialize(_presenceEmployee, _generatedOptions); [Benchmark] - public ConvPerson? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + public ConvPerson? Pres_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); [Benchmark] - public PresencePerson? Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); + public PresencePerson? Pres_Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); } public class ConvPerson From 682c9a6735d80fb33d2755be16c1cb7403c92b89 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 08:47:57 +0200 Subject: [PATCH 08/11] Move the detailed performance tables into PERFORMANCE.md The README keeps only the conclusions of the benchmark run and links to the new PERFORMANCE.md for the methodology, the machine and all scenario tables. This matches the project's doc style: usage guidance in the README, maintainer-level detail in a linked document. --- PERFORMANCE.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 58 ++++-------------------------- 2 files changed, 102 insertions(+), 52 deletions(-) create mode 100644 PERFORMANCE.md diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..b92a636 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,96 @@ +# Performance + +This document is the methodology and the full results behind the [performance summary in the README](./#performance-measured). It covers how the benchmarks are set up, the machine they ran on, and how to reproduce them. + +## How to reproduce + +The benchmarks live in the `JsonSubTypes.Benchmarks` project. Run the whole suite: + +```bash +dotnet run -c Release --project JsonSubTypes.Benchmarks +``` + +Or filter to a scenario class: + +```bash +dotnet run -c Release --project JsonSubTypes.Benchmarks --filter '*PolymorphismBenchmarks*' +``` + +The command runs every benchmark twice: once under the JIT (`DefaultJob`) and once as a native binary (`NativeAOT` job). The reflection-based engines (converter, resolver, Newtonsoft) report `NA` under the NativeAOT job: they need reflection, which the native host disables. + +### What is measured + +Each scenario is a micro-benchmark of serializing/deserializing a small object graph, declared as its polymorphic base type. The numbers below are **mean** values from a single representative run, with allocations per operation. + +The scenarios: + +- **Single object**: a `Cat` declared as its `Animal` base (two `int` properties). +- **Collection**: a list of four mixed animals (`Cat`/`Dog`), the common API payload shape. +- **Nested hierarchy**: a two-level hierarchy (`Payload → Game → Run`), discriminated by two properties. +- **Property presence**: discrimination by property presence (`KnownSubTypeWithProperty`) instead of a discriminator value. + +## Machine + +The numbers were measured on the machine BenchmarkDotNet reported in that run: + +- **CPU**: Intel Core i7-4790 @ 3.60 GHz (Haswell), 8 logical / 4 physical cores +- **OS**: Linux (Manjaro) +- **Runtime**: .NET 10.0.9 +- **BenchmarkDotNet**: 0.15.8 + +Numbers vary across machines and runs; treat them as a relative ordering, not as absolute figures for your hardware. + +## Results (JIT, .NET 10) + +### Single object + +| Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | +| :--- | ---: | ---: | ---: | +| Serialize | 1.14 µs / 856 B | 0.33 µs / 400 B | 1.18 µs / 656 B | +| Deserialize | 1.50 µs / 648 B | 0.43 µs / 56 B | 0.98 µs / 152 B | + +### Collection of 4 objects + +| Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | +| :--- | ---: | ---: | ---: | +| Serialize | 4.16 µs / 3288 B | 0.99 µs / 624 B | 4.29 µs / 2600 B | +| Deserialize | 5.64 µs / 2744 B | 1.87 µs / 784 B | 4.54 µs / 696 B | + +### Nested hierarchy and property presence + +| Benchmark | Converter (`Build()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | +| :--- | ---: | ---: | +| Nested deserialize | 1.73 µs / 1152 B | 1.15 µs / 144 B | +| Nested serialize | — (no discriminator written) | 1.54 µs / 1016 B | +| Property-presence deserialize | 1.21 µs / 776 B | 1.00 µs / 312 B | +| Property-presence serialize | 0.27 µs / 96 B | 0.27 µs / 96 B | + +Nested serialization is measured on the generated engine only: the converter falls back to the plain runtime-type contract when the leaf is registered on an intermediate base (see the README), so its write path does not inject a discriminator there. + +## Results (Native AOT) + +The generated engine is the only one compatible with Native AOT. The native build is slightly slower than JIT in steady state; its advantage is trimming compatibility and startup time, not throughput. + +| Benchmark | JIT | Native AOT | +| :--- | ---: | ---: | +| Generated_Serialize (single) | 1.18 µs / 656 B | 1.43 µs / 640 B | +| Generated_Deserialize (single) | 0.98 µs / 152 B | 1.29 µs / 152 B | + +## Newtonsoft.Json comparison + +The original `JsonSubTypes` package, through `JsonConvert`. It is a different runtime (reflection-based, no `Utf8JsonWriter`), so these are an order-of-magnitude reference, not a like-for-like benchmark. + +| Benchmark | Newtonsoft (`JsonSubTypes`) | STJ Converter (`Build()`) | +| :--- | ---: | ---: | +| Single serialize | 1.54 µs / 3.07 KB | 1.14 µs / 856 B | +| Single deserialize | 2.54 µs / 5.26 KB | 1.50 µs / 648 B | +| Collection serialize (4) | 5.51 µs / 7.53 KB | 4.16 µs / 3288 B | +| Collection deserialize (4) | 10.21 µs / 12.96 KB | 5.64 µs / 2744 B | + +## Why the engines differ + +The ordering is structural, not a tuning artifact: + +- The **resolver** is fastest because it delegates to `System.Text.Json` native polymorphism: the runtime routes the type during streaming, with no `JsonDocument` round-trip and no reflection per call. +- The **generated converter** eliminates reflection (compiled routing) but still round-trips the payload through a `JsonDocument` to inject the discriminator on write and to read it on deserialize, which is why it sits between the resolver and the converter. +- The **converter** keeps the same `JsonDocument` round-trip and adds runtime type resolution (converter scans, mapping lookups), making it the slowest of the three — and the only engine for hierarchies whose subtypes are only known at runtime. diff --git a/README.md b/README.md index 845ce53..39f6f9a 100644 --- a/README.md +++ b/README.md @@ -426,60 +426,14 @@ To preserve full compatibility with advanced features while delegating object se ### Performance (measured) -Measured with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, `DefaultJob`, **.NET 10.0**, one run per scenario). The numbers below are from a single representative run on the machine listed below; they vary across machines and runs, but the benchmark project is self-contained and you can reproduce them: +Benchmarked with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, .NET 10); the methodology, machine and full result tables are in [PERFORMANCE.md](PERFORMANCE). In short: -```bash -dotnet run -c Release --project JsonSubTypes.Benchmarks -``` - -**Machine** (what BenchmarkDotNet reported): Intel Core i7-4790 CPU 3.60 GHz (Haswell), 8 logical / 4 physical cores, Linux (Manjaro), .NET 10.0.9. - -The scenarios: a flat `Cat` declared as its `Animal` base ("single object"), a list of four mixed animals ("collection"), a two-level hierarchy (`Payload → Game → Run`, "nested"), and property-presence discrimination. The nested write scenario is measured on the generated engine only: the converter falls back to the plain runtime-type contract there and writes no discriminator, so its write number would not measure discriminator injection. - -**Single object** (JIT): - -| Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | -| :--- | ---: | ---: | ---: | -| Serialize | 1.14 µs / 856 B | 0.33 µs / 400 B | 1.18 µs / 656 B | -| Deserialize | 1.50 µs / 648 B | 0.43 µs / 56 B | 0.98 µs / 152 B | - -**Collection of 4 objects** (JIT): - -| Benchmark | Converter (`Build()`) | Resolver (`BuildResolver()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | -| :--- | ---: | ---: | ---: | -| Serialize | 4.16 µs / 3288 B | 0.99 µs / 624 B | 4.29 µs / 2600 B | -| Deserialize | 5.64 µs / 2744 B | 1.87 µs / 784 B | 4.54 µs / 696 B | - -**Nested hierarchy and property presence** (JIT): - -| Benchmark | Converter (`Build()`) | Generator (`JsonSubTypes.Text.Json.Aot`) | -| :--- | ---: | ---: | -| Nested deserialize | 1.73 µs / 1152 B | 1.15 µs / 144 B | -| Nested serialize | — (no discriminator written) | 1.54 µs / 1016 B | -| Property-presence deserialize | 1.21 µs / 776 B | 1.00 µs / 312 B | -| Property-presence serialize | 0.27 µs / 96 B | 0.27 µs / 96 B | - -Reading: the resolver is the fastest (native streaming, no double parse, no `JsonDocument`). The generated converter beats the runtime converter on deserialization and allocates far less (compiled routing instead of per-call converter scans and type resolution). On serialization the converter and the generator are close; both write the discriminator by round-tripping the payload through a `JsonDocument`, which is also why deeply nested graphs need `MaxDepth` one level higher (see above). - -**Native AOT** (generated engine, BenchmarkDotNet NativeAOT job in the same project): - -| Benchmark | JIT | Native AOT | -| :--- | ---: | ---: | -| Generated_Serialize (single) | 1.18 µs / 656 B | 1.43 µs / 640 B | -| Generated_Deserialize (single) | 0.98 µs / 152 B | 1.29 µs / 152 B | - -In steady state, Native AOT is comparable to (slightly slower than) JIT for this workload. The real AOT advantage is trimming compatibility and startup time, not steady-state throughput. - -**Newtonsoft.Json comparison** (JIT, same machine and scenarios): the original `JsonSubTypes` package, through `JsonConvert`. It is a different runtime (reflection-based, no `Utf8JsonWriter`), so treat these as an order-of-magnitude reference, not a like-for-like benchmark: - -| Benchmark | Newtonsoft (`JsonSubTypes`) | STJ Converter (`Build()`) | -| :--- | ---: | ---: | -| Single serialize | 1.54 µs / 3.07 KB | 1.14 µs / 856 B | -| Single deserialize | 2.54 µs / 5.26 KB | 1.50 µs / 648 B | -| Collection serialize (4) | 5.51 µs / 7.53 KB | 4.16 µs / 3288 B | -| Collection deserialize (4) | 10.21 µs / 12.96 KB | 5.64 µs / 2744 B | +- **Resolver (`BuildResolver()`)** is the fastest: it delegates to `System.Text.Json` native polymorphism, with no `JsonDocument` round-trip and no reflection per call. +- **Generator (`JsonSubTypes.Text.Json.Aot`)** beats the runtime converter on deserialization and allocates far less (compiled routing instead of per-call converter scans). Its Native AOT steady state is comparable to (slightly slower than) JIT; its real advantage is trimming compatibility and startup time. +- **Converter (`Build()`)** is the slowest of the three: it keeps the `JsonDocument` round-trip and adds runtime type resolution. It is the only engine for hierarchies whose subtypes are only known at runtime. +- **Newtonsoft.Json (`JsonSubTypes`)** is slower and allocates several times more than the STJ converter on the same scenarios. -Newtonsoft is slower and allocates several times more on every scenario. The STJ converter is not a drop-in replacement at the API level, but if you are on .NET 8+ the `JsonSubTypes.Text.Json` package is the faster option for the same scenarios. +Reproduce the measurements yourself with `dotnet run -c Release --project JsonSubTypes.Benchmarks`. ### Decision matrix | Use case | Recommended | From 680d4aaed7d98b2759070038ad703de275ee85df Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 09:00:48 +0200 Subject: [PATCH 09/11] Harden the reflection-based benchmarks under the Native AOT job The converter, resolver and Newtonsoft benchmarks passed their JsonSerializerOptions (or ran JsonConvert) even when reflection was disabled in the Native AOT host: serialization silently measured the default options and the deserialization benchmarks threw on a null payload, so neither produced a usable number but the doc claimed they report NA. Route their options through a guard that throws NotSupportedException when reflection is disabled, and state that in PERFORMANCE.md instead of the NA claim. --- JsonSubTypes.Benchmarks/BenchmarkGuard.cs | 32 +++++++++++++++++++ .../CollectionBenchmarks.cs | 8 ++--- .../NestedHierarchyBenchmarks.cs | 2 +- .../NewtonsoftBenchmarks.cs | 29 +++++++++++++---- JsonSubTypes.Benchmarks/Program.cs | 8 ++--- .../PropertyPresenceBenchmarks.cs | 4 +-- PERFORMANCE.md | 2 +- 7 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 JsonSubTypes.Benchmarks/BenchmarkGuard.cs diff --git a/JsonSubTypes.Benchmarks/BenchmarkGuard.cs b/JsonSubTypes.Benchmarks/BenchmarkGuard.cs new file mode 100644 index 0000000..7e627af --- /dev/null +++ b/JsonSubTypes.Benchmarks/BenchmarkGuard.cs @@ -0,0 +1,32 @@ +using System; +using System.Text.Json; + +namespace JsonSubTypes.Benchmarks +{ + // The converter, resolver and Newtonsoft benchmarks rely on reflection, which the + // Native AOT job disables. Route their options through these guards so those benchmarks + // fail loudly under the Native AOT job instead of silently running against the default + // options (which would produce plausible-looking numbers that measure nothing). + internal static class BenchmarkGuard + { + public static JsonSerializerOptions ReflectionOptions(JsonSerializerOptions? options) + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + throw new NotSupportedException( + "This engine relies on reflection, which the Native AOT host disables; the benchmark is not measured under that job."); + } + + return options ?? throw new NotSupportedException("Benchmark options were not initialized."); + } + + public static void RequireReflection() + { + if (!JsonSerializer.IsReflectionEnabledByDefault) + { + throw new NotSupportedException( + "This engine relies on reflection, which the Native AOT host disables; the benchmark is not measured under that job."); + } + } + } +} diff --git a/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs b/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs index d0d95bb..89c42be 100644 --- a/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/CollectionBenchmarks.cs @@ -76,19 +76,19 @@ public CollectionBenchmarks() } [Benchmark] - public string Col_Converter_Serialize() => JsonSerializer.Serialize(_convAnimals, _converterOptions!); + public string Col_Converter_Serialize() => JsonSerializer.Serialize(_convAnimals, BenchmarkGuard.ReflectionOptions(_converterOptions)); [Benchmark] - public string Col_Resolver_Serialize() => JsonSerializer.Serialize(_resAnimals, _resolverOptions!); + public string Col_Resolver_Serialize() => JsonSerializer.Serialize(_resAnimals, BenchmarkGuard.ReflectionOptions(_resolverOptions)); [Benchmark] public string Col_Generated_Serialize() => JsonSerializer.Serialize(_generatedAnimals, _generatedOptions); [Benchmark] - public List? Col_Converter_Deserialize() => JsonSerializer.Deserialize>(_converterJson!, _converterOptions!); + public List? Col_Converter_Deserialize() => JsonSerializer.Deserialize>(_converterJson!, BenchmarkGuard.ReflectionOptions(_converterOptions)); [Benchmark] - public List? Col_Resolver_Deserialize() => JsonSerializer.Deserialize>(_resolverJson!, _resolverOptions!); + public List? Col_Resolver_Deserialize() => JsonSerializer.Deserialize>(_resolverJson!, BenchmarkGuard.ReflectionOptions(_resolverOptions)); [Benchmark] public List? Col_Generated_Deserialize() => JsonSerializer.Deserialize>(_generatedJson, _generatedOptions); diff --git a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs index 2ac3f84..2d2800d 100644 --- a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs @@ -49,7 +49,7 @@ public NestedHierarchyBenchmarks() public string Nested_Generated_Serialize() => JsonSerializer.Serialize(_nestedRun, _generatedOptions); [Benchmark] - public ConvPayload? Nested_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + public ConvPayload? Nested_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, BenchmarkGuard.ReflectionOptions(_converterOptions)); [Benchmark] public NestedPayload? Nested_Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); diff --git a/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs b/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs index d3e2e8f..53a26b9 100644 --- a/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs @@ -6,8 +6,9 @@ namespace JsonSubTypes.Benchmarks { // Newtonsoft.Json baseline: the original package. These benchmarks mirror the single-object // and collection scenarios of the System.Text.Json benchmarks so the two packages can be - // compared. Newtonsoft runs on reflection only, so these benchmarks report NA under the - // NativeAOT job (like the reflection-based STJ engines). + // compared. Newtonsoft runs on reflection only, so under the Native AOT job these benchmarks + // fail with NotSupportedException (like the reflection-based STJ engines) and produce no + // numbers. [MemoryDiagnoser] public class NewtonsoftBenchmarks { @@ -41,16 +42,32 @@ public NewtonsoftBenchmarks() } [Benchmark] - public string Single_Serialize() => JsonConvert.SerializeObject(_animal, _settings); + public string Single_Serialize() + { + BenchmarkGuard.RequireReflection(); + return JsonConvert.SerializeObject(_animal, _settings); + } [Benchmark] - public NwAnimal? Single_Deserialize() => JsonConvert.DeserializeObject(_singleJson, _settings); + public NwAnimal? Single_Deserialize() + { + BenchmarkGuard.RequireReflection(); + return JsonConvert.DeserializeObject(_singleJson, _settings); + } [Benchmark] - public string Collection_Serialize() => JsonConvert.SerializeObject(_animals, _settings); + public string Collection_Serialize() + { + BenchmarkGuard.RequireReflection(); + return JsonConvert.SerializeObject(_animals, _settings); + } [Benchmark] - public List? Collection_Deserialize() => JsonConvert.DeserializeObject>(_collectionJson, _settings); + public List? Collection_Deserialize() + { + BenchmarkGuard.RequireReflection(); + return JsonConvert.DeserializeObject>(_collectionJson, _settings); + } } public class NwAnimal { public int Age { get; set; } } diff --git a/JsonSubTypes.Benchmarks/Program.cs b/JsonSubTypes.Benchmarks/Program.cs index 478ca30..76e10c3 100644 --- a/JsonSubTypes.Benchmarks/Program.cs +++ b/JsonSubTypes.Benchmarks/Program.cs @@ -74,16 +74,16 @@ public PolymorphismBenchmarks() public string Generated_Serialize() => JsonSerializer.Serialize(_benchCat, _generatedOptions); [Benchmark] - public string Resolver_Serialize() => JsonSerializer.Serialize(_resCat, _resolverOptions!); + public string Resolver_Serialize() => JsonSerializer.Serialize(_resCat, BenchmarkGuard.ReflectionOptions(_resolverOptions)); [Benchmark] - public string Converter_Serialize() => JsonSerializer.Serialize(new ConvCat { Age = 3, Lives = 9 }, _converterOptions!); + public string Converter_Serialize() => JsonSerializer.Serialize(new ConvCat { Age = 3, Lives = 9 }, BenchmarkGuard.ReflectionOptions(_converterOptions)); [Benchmark] - public ConvAnimal? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + public ConvAnimal? Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, BenchmarkGuard.ReflectionOptions(_converterOptions)); [Benchmark] - public ResAnimal? Resolver_Deserialize() => JsonSerializer.Deserialize(_resolverJson!, _resolverOptions!); + public ResAnimal? Resolver_Deserialize() => JsonSerializer.Deserialize(_resolverJson!, BenchmarkGuard.ReflectionOptions(_resolverOptions)); [Benchmark] public BenchAnimal? Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); diff --git a/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs b/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs index d44f088..fa583ed 100644 --- a/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs +++ b/JsonSubTypes.Benchmarks/PropertyPresenceBenchmarks.cs @@ -41,13 +41,13 @@ public PropertyPresenceBenchmarks() } [Benchmark] - public string Pres_Converter_Serialize() => JsonSerializer.Serialize(_convEmployee, _converterOptions!); + public string Pres_Converter_Serialize() => JsonSerializer.Serialize(_convEmployee, BenchmarkGuard.ReflectionOptions(_converterOptions)); [Benchmark] public string Pres_Generated_Serialize() => JsonSerializer.Serialize(_presenceEmployee, _generatedOptions); [Benchmark] - public ConvPerson? Pres_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, _converterOptions!); + public ConvPerson? Pres_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, BenchmarkGuard.ReflectionOptions(_converterOptions)); [Benchmark] public PresencePerson? Pres_Generated_Deserialize() => JsonSerializer.Deserialize(_generatedJson, _generatedOptions); diff --git a/PERFORMANCE.md b/PERFORMANCE.md index b92a636..1c92ce1 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -16,7 +16,7 @@ Or filter to a scenario class: dotnet run -c Release --project JsonSubTypes.Benchmarks --filter '*PolymorphismBenchmarks*' ``` -The command runs every benchmark twice: once under the JIT (`DefaultJob`) and once as a native binary (`NativeAOT` job). The reflection-based engines (converter, resolver, Newtonsoft) report `NA` under the NativeAOT job: they need reflection, which the native host disables. +The command runs every benchmark twice: once under the JIT (`DefaultJob`) and once as a native binary (`NativeAOT` job). The reflection-based engines (converter, resolver, Newtonsoft) cannot run under the NativeAOT job: the native host disables reflection, and their benchmark methods fail with `NotSupportedException`, so the Native AOT table below only reports the generated engine. ### What is measured From d1fdc03da0186711859639b9f23f97d646d03192 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 09:01:03 +0200 Subject: [PATCH 10/11] Fix the broken PERFORMANCE.md link, drop an unused using, and log the perf work The README linked to PERFORMANCE without the .md extension, which 404s on GitHub. The STJ converter file kept using System.IO after the MemoryStream write path was replaced by ArrayBufferWriter. The Unreleased changelog section had no entry for the converter performance work. --- CHANGELOG.md | 6 ++++++ JsonSubTypes.Text.Json/JsonSubtypes.cs | 1 - README.md | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ee87c..65beda9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deserialization with an open generic base type (e.g. `Base<>`) now closes the generic subtype correctly (e.g. `Nested1` for `Base`) instead of failing. #177 - Errors and exceptions raised while deserializing a subtype now carry the fully qualified JSON path (e.g. `Property2.Value` instead of `Value`), matching stock Newtonsoft.Json error handling. #182 +### JsonSubTypes.Text.Json +#### Changed +- The converter caches the resolved `IJsonSubtypes` converter list per `JsonSerializerOptions` (frozen on first use) and resolves single-level hierarchies without per-object allocations. +- Deserialization reads the resolved subtype from the already-parsed `JsonElement` instead of re-reading the raw bytes through a second materialization. +- The discriminator write path streams the payload as UTF-8 (`ArrayBufferWriter` + `Utf8JsonWriter`) instead of round-tripping through a UTF-16 string. + ## [1.0.0-rc.3] - 2026-08-12 ### JsonSubTypes.Text.Json diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index ec8d035..3aebe3a 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -4,7 +4,6 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; diff --git a/README.md b/README.md index 39f6f9a..6cc346c 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,7 @@ To preserve full compatibility with advanced features while delegating object se ### Performance (measured) -Benchmarked with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, .NET 10); the methodology, machine and full result tables are in [PERFORMANCE.md](PERFORMANCE). In short: +Benchmarked with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, .NET 10); the methodology, machine and full result tables are in [PERFORMANCE.md](PERFORMANCE.md). In short: - **Resolver (`BuildResolver()`)** is the fastest: it delegates to `System.Text.Json` native polymorphism, with no `JsonDocument` round-trip and no reflection per call. - **Generator (`JsonSubTypes.Text.Json.Aot`)** beats the runtime converter on deserialization and allocates far less (compiled routing instead of per-call converter scans). Its Native AOT steady state is comparable to (slightly slower than) JIT; its real advantage is trimming compatibility and startup time. From 911e3d32a4f9660f77ce772ab42fdbcbbd52e5e6 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sun, 16 Aug 2026 09:27:39 +0200 Subject: [PATCH 11/11] Document reproduction and add a verified benchmark sample run Anyone arriving on the repo should be able to reproduce the measurements: the documented command previously launched BenchmarkDotNet interactively (it prompted for a selection), so it needed the --filter argument forwarded with --. State the prerequisites (net10 SDK, native compiler for the NativeAOT job), add the usual micro-benchmark disclaimers, note the runtime used, and embed a verbatim sample run taken from a fresh execution on the documented machine that reproduces the reported tables within run-to-run noise. --- PERFORMANCE.md | 60 +++++++++++++++++++++++++++++++++++++------------- README.md | 2 +- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 1c92ce1..8ea89b9 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -4,41 +4,43 @@ This document is the methodology and the full results behind the [performance su ## How to reproduce +Prerequisites: + +- A .NET 10 SDK (`dotnet --version` >= 10.0.100). +- A native compiler for the `NativeAOT` job: `clang` on Linux, the "Desktop development with C++" workload on Windows, or the Xcode Command Line Tools on macOS. The first run compiles the whole benchmark host as a native binary, which takes a few minutes. + The benchmarks live in the `JsonSubTypes.Benchmarks` project. Run the whole suite: ```bash -dotnet run -c Release --project JsonSubTypes.Benchmarks +dotnet run -c Release --project JsonSubTypes.Benchmarks -- --filter "*" ``` Or filter to a scenario class: ```bash -dotnet run -c Release --project JsonSubTypes.Benchmarks --filter '*PolymorphismBenchmarks*' +dotnet run -c Release --project JsonSubTypes.Benchmarks -- --filter '*PolymorphismBenchmarks*' ``` -The command runs every benchmark twice: once under the JIT (`DefaultJob`) and once as a native binary (`NativeAOT` job). The reflection-based engines (converter, resolver, Newtonsoft) cannot run under the NativeAOT job: the native host disables reflection, and their benchmark methods fail with `NotSupportedException`, so the Native AOT table below only reports the generated engine. - -### What is measured +The command runs every benchmark twice: once under the JIT (`DefaultJob`) and once as a native binary (`NativeAOT` job). The reflection-based engines (converter, resolver, Newtonsoft) cannot run under the NativeAOT job: the native host disables reflection, and their benchmark methods throw `NotSupportedException`, so BenchmarkDotNet reports them as `NA`/failed there and the Native AOT table below only reports the generated engine. -Each scenario is a micro-benchmark of serializing/deserializing a small object graph, declared as its polymorphic base type. The numbers below are **mean** values from a single representative run, with allocations per operation. +## Caveats -The scenarios: - -- **Single object**: a `Cat` declared as its `Animal` base (two `int` properties). -- **Collection**: a list of four mixed animals (`Cat`/`Dog`), the common API payload shape. -- **Nested hierarchy**: a two-level hierarchy (`Payload → Game → Run`), discriminated by two properties. -- **Property presence**: discrimination by property presence (`KnownSubTypeWithProperty`) instead of a discriminator value. +- These are **micro-benchmarks**: they measure a narrow scenario in isolation (a small object graph declared as its polymorphic base type), not real application throughput. Use them as a relative ordering between the engines, not as a prediction of end-to-end performance. +- The numbers are specific to the machine and configuration below. **Always measure on your own hardware** before making a decision. +- Times vary between runs and across runtime/OS/BenchmarkDotNet versions; a few percent of run-to-run variance is normal (the machine here is a 2014 desktop CPU). Allocations are stable and are the more reliable figure. +- Each reported value is the **mean** of a single BenchmarkDotNet run (warm-up + multiple iterations, outliers removed), as shown in the [sample run](#sample-run-verified) below. +- Never compare times across machines. Comparing allocations across machines is meaningful. ## Machine -The numbers were measured on the machine BenchmarkDotNet reported in that run: +The numbers below and in the sample run were measured on the same machine, which BenchmarkDotNet reports as: - **CPU**: Intel Core i7-4790 @ 3.60 GHz (Haswell), 8 logical / 4 physical cores - **OS**: Linux (Manjaro) -- **Runtime**: .NET 10.0.9 +- **Runtime**: .NET 10.0.10 (SDK 10.0.110) - **BenchmarkDotNet**: 0.15.8 -Numbers vary across machines and runs; treat them as a relative ordering, not as absolute figures for your hardware. +The results were originally measured on .NET 10.0.9 and re-verified on .NET 10.0.10 ([sample run](#sample-run-verified)); the numbers reproduced within the run-to-run noise. ## Results (JIT, .NET 10) @@ -94,3 +96,31 @@ The ordering is structural, not a tuning artifact: - The **resolver** is fastest because it delegates to `System.Text.Json` native polymorphism: the runtime routes the type during streaming, with no `JsonDocument` round-trip and no reflection per call. - The **generated converter** eliminates reflection (compiled routing) but still round-trips the payload through a `JsonDocument` to inject the discriminator on write and to read it on deserialize, which is why it sits between the resolver and the converter. - The **converter** keeps the same `JsonDocument` round-trip and adds runtime type resolution (converter scans, mapping lookups), making it the slowest of the three — and the only engine for hierarchies whose subtypes are only known at runtime. + +## Sample run (verified) + +Verbatim output of the reproduction command above (`--filter "*"`) on the documented machine, .NET 10.0.10 / BenchmarkDotNet 0.15.8, run on 2026-08-16. The single-object summary is shown; the collection, nested-hierarchy, property-presence and Newtonsoft summaries reproduced the corresponding tables within the same run-to-run noise. The reflection-based rows under `NativeAOT` are the expected `NA` (see [How to reproduce](#how-to-reproduce)). + +``` +BenchmarkDotNet v0.15.8, Linux Manjaro Linux +Intel Core i7-4790 CPU 3.60GHz (Max: 0.80GHz) (Haswell), 1 CPU, 8 logical and 4 physical cores +.NET SDK 10.0.110 + [Host] : .NET 10.0.10 (10.0.10, 42.42.42.42424), X64 RyuJIT x86-64-v3 + DefaultJob : .NET 10.0.10 (10.0.10, 42.42.42.42424), X64 RyuJIT x86-64-v3 + NativeAOT : .NET 10.0.10, X64 NativeAOT x86-64-v3 + +| Method | Job | Toolchain | Mean | Error | StdDev | Gen0 | Allocated | +|---------------------- |----------- |------------------ |-----------:|---------:|---------:|-------:|----------:| +| Generated_Serialize | DefaultJob | Default | 1,136.9 ns | 22.51 ns | 24.09 ns | 0.1564 | 656 B | +| Resolver_Serialize | DefaultJob | Default | 336.5 ns | 5.12 ns | 4.79 ns | 0.0954 | 400 B | +| Converter_Serialize | DefaultJob | Default | 1,175.4 ns | 23.17 ns | 33.23 ns | 0.2041 | 856 B | +| Converter_Deserialize | DefaultJob | Default | 1,486.4 ns | 20.95 ns | 17.50 ns | 0.1545 | 648 B | +| Resolver_Deserialize | DefaultJob | Default | 397.2 ns | 4.48 ns | 3.97 ns | 0.0134 | 56 B | +| Generated_Deserialize | DefaultJob | Default | 1,020.0 ns | 20.33 ns | 23.41 ns | 0.0362 | 152 B | +| Generated_Serialize | NativeAOT | Latest ILCompiler | 1,477.5 ns | 27.75 ns | 27.25 ns | 0.1526 | 640 B | +| Resolver_Serialize | NativeAOT | Latest ILCompiler | NA | NA | NA | NA | NA | +| Converter_Serialize | NativeAOT | Latest ILCompiler | NA | NA | NA | NA | NA | +| Converter_Deserialize | NativeAOT | Latest ILCompiler | NA | NA | NA | NA | NA | +| Resolver_Deserialize | NativeAOT | Latest ILCompiler | NA | NA | NA | NA | NA | +| Generated_Deserialize | NativeAOT | Latest ILCompiler | 1,251.0 ns | 24.31 ns | 29.85 ns | 0.0362 | 152 B | +``` diff --git a/README.md b/README.md index 6cc346c..5a1dc76 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ Benchmarked with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, .NET 10); the metho - **Converter (`Build()`)** is the slowest of the three: it keeps the `JsonDocument` round-trip and adds runtime type resolution. It is the only engine for hierarchies whose subtypes are only known at runtime. - **Newtonsoft.Json (`JsonSubTypes`)** is slower and allocates several times more than the STJ converter on the same scenarios. -Reproduce the measurements yourself with `dotnet run -c Release --project JsonSubTypes.Benchmarks`. +Reproduce the measurements yourself with `dotnet run -c Release --project JsonSubTypes.Benchmarks -- --filter "*"` (a native compiler is needed for the Native AOT job; see [PERFORMANCE.md](PERFORMANCE.md)). ### Decision matrix | Use case | Recommended |