runtime: System.Text.Json's generator causes considerable package size increase

We’re currently working on migrating all our JSON serialization from Newtonsoft.Json to System.Text.Json in the Microsoft Store, and we’re hitting some issues with respect to binary size after enabling the source generators for all our types. Our data models for JSON responses the Store client handles from service are about 260, which once annotated over a context, transitively cause a very large number of types (ie. JsonTypeInfo<T> properties) to be generated, precisely 742 of them (this also includes eg. collection types with any of these data types as element type, etc.). That’s a lot 😅

When trying this out, this caused our package size to regress from 59MB to about 76MB, so that’s a 17MB (~29%) increase. This is for a final package with x86, x64 and Arm64 architectures, compiled fully AOT with .NET Native, with trimming enabled. For reference, with trimming disabled we baseline around 80MB without System.Text.Json, so I’d expect the version with System.Text.Json to be around the 100MB mark. We’ll have to investigate whether the tradeoff for this size regression is worth the benefits of the source generators here (specifically for us, faster performance and less memory use when deserializing responses, and reliable behavior that’s trimmer-safe), but I wanted to open this issue to investigate whether the generator can also be improved to reduce the metadata increase it causes.

For context: all these models are generated in metadata only mode, so the size increase is with just the metadata support code. No serialization fast-path is generated. One thing I noticed is, we have dozens and dozens of files like this, generated due to the transitive closure of the types we have annotated:

Generated code (click to expand):
// <auto-generated/>

#nullable enable annotations
#nullable disable warnings

// Suppress warnings about [Obsolete] member usage in generated code.
#pragma warning disable CS0618

namespace MyProject
{
    internal sealed partial class MyJsonSerializerContext
    {
        private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::System.Collections.Generic.ICollection<global::SomeModelType>>? _ICollectionSomeModelType;
        /// <summary>
        /// Defines the source generated JSON serialization contract metadata for a given type.
        /// </summary>
        public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::System.Collections.Generic.ICollection<global::SomeModelType>> ICollectionSomeModelType
        {
            get => _ICollectionSomeModelType ??= Create_ICollectionSomeModelType(Options);
        }
        
        // Intentionally not a static method because we create a delegate to it. Invoking delegates to instance
        // methods is almost as fast as virtual calls. Static methods need to go through a shuffle thunk.
        private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::System.Collections.Generic.ICollection<global::SomeModelType>> Create_ICollectionSomeModelType(global::System.Text.Json.JsonSerializerOptions options)
        {
            global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::System.Collections.Generic.ICollection<global::SomeModelType>>? jsonTypeInfo = null;
            global::System.Text.Json.Serialization.JsonConverter? customConverter;
            if (options.Converters.Count > 0 && (customConverter = GetRuntimeProvidedCustomConverter(options, typeof(global::System.Collections.Generic.ICollection<global::SomeModelType>))) != null)
            {
                jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::System.Collections.Generic.ICollection<global::SomeModelType>>(options, customConverter);
            }
            else
            {
                global::System.Text.Json.Serialization.Metadata.JsonCollectionInfoValues<global::System.Collections.Generic.ICollection<global::SomeModelType>> info = new global::System.Text.Json.Serialization.Metadata.JsonCollectionInfoValues<global::System.Collections.Generic.ICollection<global::SomeModelType>>()
                {
                    ObjectCreator = null,
                    NumberHandling = default,
                    SerializeHandler = null
                };
        
                jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateICollectionInfo<global::System.Collections.Generic.ICollection<global::SomeModelType>, global::SomeModelType>(options, info);
        
            }
        
            return jsonTypeInfo;
        }        
    }
}

In this case, the Create_ICollectionSomeModelType method is effectively the same as dozens of other files, just with a different type argument. One idea for the generator to produce less code is for it to identify all these cases where the “default” path is used (ie. there’s no custom converter known at compile time for this type), and just emit a shared stub just once, that the various properties can then reuse. For instance, it could be something like this:

Generated code (click to expand):
private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::System.Collections.Generic.ICollection<T>> Create_ICollectionForType<T>(global::System.Text.Json.JsonSerializerOptions options)
{
    global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::System.Collections.Generic.ICollection<T>>? jsonTypeInfo = null;
    global::System.Text.Json.Serialization.JsonConverter? customConverter;
    if (options.Converters.Count > 0 && (customConverter = GetRuntimeProvidedCustomConverter(options, typeof(global::System.Collections.Generic.ICollection<T>))) != null)
    {
        jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::System.Collections.Generic.ICollection<T>>(options, customConverter);
    }
    else
    {
        global::System.Text.Json.Serialization.Metadata.JsonCollectionInfoValues<global::System.Collections.Generic.ICollection<T>> info = new global::System.Text.Json.Serialization.Metadata.JsonCollectionInfoValues<global::System.Collections.Generic.ICollection<T>>()
        {
            ObjectCreator = null,
            NumberHandling = default,
            SerializeHandler = null
        };

        jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateICollectionInfo<global::System.Collections.Generic.ICollection<T>, T>(options, info);
    }

    return jsonTypeInfo;
}

And then the property would just do:

public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::System.Collections.Generic.ICollection<global::SomeModelType>> ICollectionSomeModelType
{
    get => _ICollectionSomeModelType ??= Create_ICollectionForType<global::SomeModelType>(Options);
}

This trick can work for all “default” cases for several collection types as well (maybe for some concrete types as well, would have to check). Point is: the generator should identify all generated methods that have a high amount of shared code with others, and if possible rewrite them to be a single shared, generic method that all other consumers can invoke, instead of having their own copy.

This would likely give us very nice improvements in terms of code size, if you consider this multiplied over several dozens of types.

Let me know if we want a separate meta issue to track general size improvements for the generators, or if we just want to use this one as reference 🙂

cc. @eiriktsarpalis

Known Workarounds

Don’t use the generators at all. Not desireable due to performance and trimming concerns.

Configuration

  • System.Text.Json 7.0.0-rc.2.22472.3

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 24 (24 by maintainers)

Most upvoted comments

Yup, sounds good! Still a regression (which is by design) compared to no generators, but it’s much better than before 🙂 I’ll run a couple pipelines and leave an updated size diff here using the latest nightly build just for future reference. Thank you for all the help!! 🙌

“This can also be controlled by modelling your DTOs to be serialization-only (e.g. by removing public constructors or property setters).”

Possibly a dumb question, but how would one create instances of these models to serialize then 😅

I will also say I understand the point about diminishing returns though, I think maybe it’s less noticeable now given that there’s still the issue with too much generated code not being shared and also non-public members and ignored ones still being included in the generated metadata (ie. a mix of this issue, #77675 and #66679). Once those are addressed I agree it’s possible that such a serialization-only mode wouldn’t seem worth it anymore. Either way, it does seem like something we should only potentially reconsider again once these other issues are addressed, so yeah makes sense to shelve that for now 🙂

cc: @SilentCC who volunteered to help with JsonIgnore investigation

Having that be changed to be opt-in sounds like a great solution to me. I’d also be happy to help if you wanted to try this out with a nightly build from a PR, I could use it in the Store and see how much of an impact it’d have, so we can get an idea 🙂

We could, although it’s likely some users will take a dependency on the .NET 7 behavior. We’d need to file a breaking change.

@eiriktsarpalis I think it makes sense to remove them completely, users can add them back if they need them, right?

“Is this project public by any chance?”

It’s the Microsoft Store, so it’s unfortunately not public 😅 Happy to direct you to the repo though and help creating a repro if you ping me on Teams.