-
Notifications
You must be signed in to change notification settings - Fork 4.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Size saving opportunity for System.Text.Json generators with custom converters #84015
Comments
Tagging subscribers to this area: @dotnet/area-system-text-json, @gregsdennis Issue DetailsDescriptionI was looking at the generated code from System.Text.Json in a few scenarios and noticed another case where we could get some small but nice size saving improvements, in a way that's proportional to how many custom converters a given user has. Consider: [JsonConverter(typeof(MyConverter<MyEnum>))]
public enum MyEnum
{
} If this type is directly or indirectly discovered by the generator, you'll end up with the following: Full 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
public sealed partial class MyContext
{
private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum>? _MyEnum;
/// <summary>
/// Defines the source generated JSON serialization contract metadata for a given type.
/// </summary>
public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum> MyEnum
{
get => _MyEnum ??= Create_MyEnum(Options, makeReadOnly: true);
}
private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum> Create_MyEnum(global::System.Text.Json.JsonSerializerOptions options, bool makeReadOnly)
{
global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum>? jsonTypeInfo = null;
global::System.Text.Json.Serialization.JsonConverter? customConverter;
if (options.Converters.Count > 0 && (customConverter = GetRuntimeProvidedCustomConverter(options, typeof(global::MyEnum))) != null)
{
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum>(options, customConverter);
}
else
{
global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
global::System.Type typeToConvert = typeof(global::MyEnum);
if (!converter.CanConvert(typeToConvert))
{
throw new global::System.InvalidOperationException(string.Format("The converter '{0}' is not compatible with the type '{1}'.", converter.GetType(), typeToConvert));
}
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum> (options, converter);
}
if (makeReadOnly)
{
jsonTypeInfo.MakeReadOnly();
}
return jsonTypeInfo;
}
} In particular, this bit: global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
global::System.Type typeToConvert = typeof(global::MyEnum);
if (!converter.CanConvert(typeToConvert))
{
throw new global::System.InvalidOperationException(string.Format("The converter '{0}' is not compatible with the type '{1}'.", converter.GetType(), typeToConvert));
} That extra check is done inline for every single type that has a custom converter, which means you also repeat the full codegen for that interpolation + throw expression. I'm thinking there could be 2 possible options to fix this, each with pros and cons. Option 1The generator could simply emit a single shared helper once if there is at least a type using a custom converter in the JSON context geing generated. Then, it'd just reuse that shared helper every time it's emitting code to create a custom type converter: [MethodImpl(MethodImplOptions.NoInlining)]
private static void ValidateCustomConverter(JsonConverter converter, Type typeToConvert)
{
if (!converter.CanConvert(typeToConvert))
{
throw new global::System.InvalidOperationException(string.Format("The converter '{0}' is not compatible with the type '{1}'.", converter.GetType(), typeToConvert));
}
} And then all other callsites would simply call it: global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
ValidateCustomConverter(converter, typeof(global::MyEnum));
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum> (options, converter); Option 2If we can add the check to global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum> (options, converter); And then that I don't expect this to bring in massive size deltas, but still seems like an improvement that would make sense? cc. @eiriktsarpalis @layomia @krwq Reproduction Stepsn/a Expected behaviorn/a Actual behaviorn/a Regression?No response Known WorkaroundsNo response ConfigurationNo response Other informationNo response
|
I definitely think we could avoid quite a bit of duplication in the converter resolution logic 👍 |
Tagging subscribers to 'size-reduction': @eerhardt, @SamMonoRT, @marek-safar Issue DetailsDescriptionI was looking at the generated code from System.Text.Json in a few scenarios and noticed another case where we could get some small but nice size saving improvements, in a way that's proportional to how many custom converters a given user has. Consider: [JsonConverter(typeof(MyConverter<MyEnum>))]
public enum MyEnum
{
} If this type is directly or indirectly discovered by the generator, you'll end up with the following: Full 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
public sealed partial class MyContext
{
private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum>? _MyEnum;
/// <summary>
/// Defines the source generated JSON serialization contract metadata for a given type.
/// </summary>
public global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum> MyEnum
{
get => _MyEnum ??= Create_MyEnum(Options, makeReadOnly: true);
}
private global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum> Create_MyEnum(global::System.Text.Json.JsonSerializerOptions options, bool makeReadOnly)
{
global::System.Text.Json.Serialization.Metadata.JsonTypeInfo<global::MyEnum>? jsonTypeInfo = null;
global::System.Text.Json.Serialization.JsonConverter? customConverter;
if (options.Converters.Count > 0 && (customConverter = GetRuntimeProvidedCustomConverter(options, typeof(global::MyEnum))) != null)
{
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum>(options, customConverter);
}
else
{
global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
global::System.Type typeToConvert = typeof(global::MyEnum);
if (!converter.CanConvert(typeToConvert))
{
throw new global::System.InvalidOperationException(string.Format("The converter '{0}' is not compatible with the type '{1}'.", converter.GetType(), typeToConvert));
}
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum> (options, converter);
}
if (makeReadOnly)
{
jsonTypeInfo.MakeReadOnly();
}
return jsonTypeInfo;
}
} In particular, this bit: global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
global::System.Type typeToConvert = typeof(global::MyEnum);
if (!converter.CanConvert(typeToConvert))
{
throw new global::System.InvalidOperationException(string.Format("The converter '{0}' is not compatible with the type '{1}'.", converter.GetType(), typeToConvert));
} That extra check is done inline for every single type that has a custom converter, which means you also repeat the full codegen for that interpolation + throw expression. I'm thinking there could be 2 possible options to fix this, each with pros and cons. Option 1The generator could simply emit a single shared helper once if there is at least a type using a custom converter in the JSON context geing generated. Then, it'd just reuse that shared helper every time it's emitting code to create a custom type converter: [MethodImpl(MethodImplOptions.NoInlining)]
private static void ValidateCustomConverter(JsonConverter converter, Type typeToConvert)
{
if (!converter.CanConvert(typeToConvert))
{
throw new global::System.InvalidOperationException(string.Format("The converter '{0}' is not compatible with the type '{1}'.", converter.GetType(), typeToConvert));
}
} And then all other callsites would simply call it: global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
ValidateCustomConverter(converter, typeof(global::MyEnum));
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum> (options, converter); Option 2If we can add the check to global::System.Text.Json.Serialization.JsonConverter converter = new global::MyConverter<global::MyEnum>();
jsonTypeInfo = global::System.Text.Json.Serialization.Metadata.JsonMetadataServices.CreateValueInfo<global::MyEnum> (options, converter); And then that As a bonus point, this would also simplify the generator a bit, since it wouldn't have to generate that check at all anymore. I don't expect this to bring in massive size deltas, but still seems like an improvement that would make sense? cc. @eiriktsarpalis @layomia @krwq
|
@eiriktsarpalis leaving some binary size numbers here on the Microsoft Store in case they're useful 🙂
Deltas (mostly because of #86526, I assume):
Not entirely sure why the x64 (and Arm64/x86) binaries seem very marginally larger, but the overall package has had a slight improvement anyway, so looks good! Those generator changes were very minimal anyway so no major deltas were expected, but figured I might share numbers just in case. While I'm at it, I can also share the current numbers compared to 7.0.0.
Deltas:
Looking great!! 🚀 |
Thanks for sharing those results, @Sergio0694! |
Thanks for sharing. The diff around #86526 seems reasonable, however I'm surprised that the difference with .NET 7 is so small. When measuring size in aspnet's BasicMinimalAPI I'm seeing reductions in the order of 80%. Would you be able to share size numbers of your classes when publishing to Native AOT? |
Just to clarify (for others that might be reading too), all those numbers I just shared are with .NET Native, not NativeAOT. And yeah sure thing, I'll run the same tests on that minimal repro with the same JSON models (the one we also used to investigate #84922) with NativeAOT and share numbers with that as well using all packages I also used here 🙂 |
Tested again, numbers look pretty good! Note: currently testing with our contracts package which is just .NET Standard 2.0, and with everything left to default values. Basically the same setup we're using on UWP, except that the test project here was .NET 7/8 instead. For the .NET 8 version, I've also set
Deltas:
Looking really really good on .NET 8! 🚀 Would you like me to open a separate issue to track this, in case it's useful? I could periodically share numbers with our setup. Also, would it maybe be useful to add this project to the JSON testing suite for size regressions and whatnot? Is this something you might be interested in, and would it be doable even though we can't make our source public? 🤔
|
These are all methods added in .NET 8, I suspect that even though you were using the v8 STJ package at build time, the linker somehow ended up with a v7 System.Text.Json run time dll. It could be an issue with your project configuration or a bug in the linker infrastructure. |
Description
I was looking at the generated code from System.Text.Json in a few scenarios and noticed another case where we could get some small but nice size saving improvements, in a way that's proportional to how many custom converters a given user has. Consider:
If this type is directly or indirectly discovered by the generator, you'll end up with the following:
Full generated code (click to expand):
In particular, this bit:
That extra check is done inline for every single type that has a custom converter, which means you also repeat the full codegen for that interpolation + throw expression. I'm thinking there could be 2 possible options to fix this, each with pros and cons.
Option 1
The generator could simply emit a single shared helper once if there is at least a type using a custom converter in the JSON context geing generated. Then, it'd just reuse that shared helper every time it's emitting code to create a custom type converter:
And then all other callsites would simply call it:
Option 2
If we can add the check to
JsonMetadataServices.CreateValueInfo
, this would be even simpler and likely use less size still:And then that
CreateValueInfo
call would just throw if the input converter doesn't support typeT
. Which I feel would make sense to test anyway in case it doesn't already? As in, why would you want a value info for a converter that's just invalid anyway?As a bonus point, this would also simplify the generator a bit, since it wouldn't have to generate that check at all anymore.
I don't expect this to bring in massive size deltas, but still seems like an improvement that would make sense?
cc. @eiriktsarpalis @layomia @krwq
The text was updated successfully, but these errors were encountered: