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.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 new file mode 100644 index 0000000..89c42be --- /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 Col_Converter_Serialize() => JsonSerializer.Serialize(_convAnimals, BenchmarkGuard.ReflectionOptions(_converterOptions)); + + [Benchmark] + 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!, BenchmarkGuard.ReflectionOptions(_converterOptions)); + + [Benchmark] + public List? Col_Resolver_Deserialize() => JsonSerializer.Deserialize>(_resolverJson!, BenchmarkGuard.ReflectionOptions(_resolverOptions)); + + [Benchmark] + public List? Col_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 a55dbb4..c7d54fb 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 @@ -18,5 +18,6 @@ + diff --git a/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs b/JsonSubTypes.Benchmarks/NestedHierarchyBenchmarks.cs new file mode 100644 index 0000000..2d2800d --- /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 Nested_Generated_Serialize() => JsonSerializer.Serialize(_nestedRun, _generatedOptions); + + [Benchmark] + public ConvPayload? Nested_Converter_Deserialize() => JsonSerializer.Deserialize(_converterJson!, BenchmarkGuard.ReflectionOptions(_converterOptions)); + + [Benchmark] + public NestedPayload? Nested_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..53a26b9 --- /dev/null +++ b/JsonSubTypes.Benchmarks/NewtonsoftBenchmarks.cs @@ -0,0 +1,76 @@ +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 under the Native AOT job these benchmarks + // fail with NotSupportedException (like the reflection-based STJ engines) and produce no + // numbers. + [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() + { + BenchmarkGuard.RequireReflection(); + return JsonConvert.SerializeObject(_animal, _settings); + } + + [Benchmark] + public NwAnimal? Single_Deserialize() + { + BenchmarkGuard.RequireReflection(); + return JsonConvert.DeserializeObject(_singleJson, _settings); + } + + [Benchmark] + public string Collection_Serialize() + { + BenchmarkGuard.RequireReflection(); + return JsonConvert.SerializeObject(_animals, _settings); + } + + [Benchmark] + public List? Collection_Deserialize() + { + BenchmarkGuard.RequireReflection(); + return 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 2e488ad..76e10c3 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 { @@ -17,7 +18,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); @@ -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() @@ -73,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 new file mode 100644 index 0000000..fa583ed --- /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 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!, BenchmarkGuard.ReflectionOptions(_converterOptions)); + + [Benchmark] + public PresencePerson? Pres_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/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/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 870c211..3aebe3a 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -1,12 +1,12 @@ using System; +using System.Buffers; using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using System.Reflection; -using System.Text; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; @@ -131,6 +131,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; @@ -203,8 +210,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; } @@ -221,13 +232,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; } @@ -276,8 +286,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) @@ -554,7 +564,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) @@ -574,20 +584,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 +622,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 +846,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 diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..8ea89b9 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,126 @@ +# 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 + +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 -- --filter "*" +``` + +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) 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. + +## Caveats + +- 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 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.10 (SDK 10.0.110) +- **BenchmarkDotNet**: 0.15.8 + +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) + +### 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. + +## 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 96d445e..5a1dc76 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): @@ -416,23 +426,14 @@ 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. - -| 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 | - -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). - -**Native AOT** (generated engine, measured with a BenchmarkDotNet NativeAOT job in the same benchmark project): +Benchmarked with BenchmarkDotNet (`JsonSubTypes.Benchmarks`, .NET 10); the methodology, machine and full result tables are in [PERFORMANCE.md](PERFORMANCE.md). In short: -| Benchmark | DefaultJob (JIT) | Native 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 | +- **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. -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. +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 |